D:\a\csshw\csshw\src\daemon\mod.rs
Line | Count | Source |
1 | | //! Daemon implementation |
2 | | |
3 | | #![deny(clippy::implicit_return)] |
4 | | #![allow(clippy::needless_return, clippy::doc_overindented_list_items)] |
5 | | #![warn(missing_docs)] |
6 | | |
7 | | use std::collections::HashMap; |
8 | | use std::{ |
9 | | io, |
10 | | sync::{Arc, Mutex}, |
11 | | time::Duration, |
12 | | }; |
13 | | use std::{thread, time}; |
14 | | |
15 | | use crate::get_console_window_handle; |
16 | | use crate::protocol::{ |
17 | | deserialization::deserialize_pid, |
18 | | serialization::{serialize_client_state, serialize_highlight, serialize_input_record_0}, |
19 | | ClientState, FRAMED_HIGHLIGHT_LENGTH, FRAMED_INPUT_RECORD_LENGTH, FRAMED_STATE_CHANGE_LENGTH, |
20 | | SERIALIZED_INPUT_RECORD_0_LENGTH, SERIALIZED_PID_LENGTH, TAG_HIGHLIGHT, TAG_INPUT_RECORD, |
21 | | TAG_KEEP_ALIVE, TAG_STATE_CHANGE, |
22 | | }; |
23 | | use crate::utils::config::{Cluster, DaemonConfig, EdgeBehavior}; |
24 | | use crate::utils::debug::StringRepr; |
25 | | use crate::utils::windows::{clear_screen, set_console_color, WindowsApi}; |
26 | | use crate::{ |
27 | | current_exe_path, spawn_console_process, |
28 | | utils::{ |
29 | | constants::{PIPE_NAME, PKG_NAME}, |
30 | | windows::{ |
31 | | arrange_console, get_console_input_buffer, read_keyboard_input, |
32 | | set_console_border_color, |
33 | | }, |
34 | | }, |
35 | | WindowsSettingsDefaultTerminalApplicationGuard, |
36 | | }; |
37 | | use bracoxide::explode; |
38 | | use log::{debug, error, warn}; |
39 | | use tokio::{ |
40 | | net::windows::named_pipe::{NamedPipeServer, PipeMode, ServerOptions}, |
41 | | sync::{ |
42 | | broadcast::{self, error::RecvError, Receiver, Sender}, |
43 | | watch, |
44 | | }, |
45 | | task::JoinHandle, |
46 | | }; |
47 | | use windows::Win32::System::Console::{ |
48 | | CONSOLE_CHARACTER_ATTRIBUTES, INPUT_RECORD_0, KEY_EVENT_RECORD, LEFT_ALT_PRESSED, |
49 | | LEFT_CTRL_PRESSED, RIGHT_ALT_PRESSED, RIGHT_CTRL_PRESSED, SHIFT_PRESSED, |
50 | | }; |
51 | | |
52 | | use windows::Win32::UI::Input::KeyboardAndMouse::{ |
53 | | VIRTUAL_KEY, VK_A, VK_C, VK_D, VK_DOWN, VK_E, VK_ESCAPE, VK_H, VK_J, VK_K, VK_L, VK_LEFT, VK_N, |
54 | | VK_R, VK_RIGHT, VK_T, VK_UP, |
55 | | }; |
56 | | use windows::Win32::UI::WindowsAndMessaging::{SW_RESTORE, SW_SHOWMINIMIZED, SW_SHOWNOACTIVATE}; |
57 | | use windows::Win32::{ |
58 | | Foundation::{COLORREF, HANDLE, HWND, STILL_ACTIVE}, |
59 | | System::{Console::ENABLE_PROCESSED_INPUT, Threading::PROCESS_QUERY_INFORMATION}, |
60 | | }; |
61 | | |
62 | | use self::grid::{grid_dimensions, ClientGrid}; |
63 | | use self::workspace::WorkspaceArea; |
64 | | |
65 | | mod grid; |
66 | | mod workspace; |
67 | | |
68 | | /// The capacity of the broadcast channel used |
69 | | /// to send the input records read from the console input buffer |
70 | | /// to the named pipe servers connected to each client in parallel. |
71 | | const SENDER_CAPACITY: usize = 1024 * 1024; |
72 | | |
73 | | /// Bits in `KEY_EVENT_RECORD::dwControlKeyState` that represent |
74 | | /// "real" modifier keys (Ctrl / Alt / Shift) as opposed to lock |
75 | | /// toggles (`CAPSLOCK_ON`, `NUMLOCK_ON`, `SCROLLLOCK_ON`) or the |
76 | | /// `ENHANCED_KEY` flag. |
77 | | /// |
78 | | /// Control-mode key classification ANDs `dwControlKeyState` with |
79 | | /// this mask before matching; otherwise an enabled CapsLock or |
80 | | /// NumLock would make `dwControlKeyState` non-zero and silently |
81 | | /// skip every `(VK_*, 0)` arm. |
82 | | const MODIFIER_MASK: u32 = |
83 | | LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED | LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED | SHIFT_PRESSED; |
84 | | |
85 | | /// Top-level control-mode action a keystroke classifies into. |
86 | | /// |
87 | | /// Extracted from [`Daemon::handle_input_record`]'s dispatch match |
88 | | /// so the classification - including the [`MODIFIER_MASK`] step - |
89 | | /// can be regression tested without instantiating a full |
90 | | /// [`Daemon`]. |
91 | | #[derive(Debug, PartialEq, Eq)] |
92 | | enum ControlModeAction { |
93 | | /// `[r]` - rearrange every client window. |
94 | | Retile, |
95 | | /// `[e]` - open the enable/disable input submenu. |
96 | | OpenEnableDisableSubmenu, |
97 | | /// `[t]` - flip each client's [`ClientState`]. |
98 | | ToggleEnabled, |
99 | | /// `[n]` - force every client back to [`ClientState::Active`]. |
100 | | EnableAll, |
101 | | /// `[c]` - prompt for new hostnames and launch additional clients. |
102 | | CreateWindows, |
103 | | /// `[h]` - copy the active clients' hostnames to the clipboard. |
104 | | CopyHostnames, |
105 | | /// Any other key in the active control-mode prompt. |
106 | | NoOp, |
107 | | } |
108 | | |
109 | | /// Enable/disable-submenu action a keystroke classifies into. |
110 | | /// |
111 | | /// Extracted from [`Daemon::handle_enable_disable_submenu_key`]'s |
112 | | /// dispatch match for the same reason as [`ControlModeAction`]. |
113 | | #[derive(Debug, PartialEq, Eq)] |
114 | | enum EnableDisableSubmenuAction { |
115 | | /// `[e]` - force the targeted client(s) to [`ClientState::Active`]. |
116 | | Enable, |
117 | | /// `[d]` - force the targeted client(s) to [`ClientState::Disabled`]. |
118 | | Disable, |
119 | | /// `[t]` - flip the targeted client(s)' [`ClientState`]. |
120 | | Toggle, |
121 | | /// Arrow key or vim motion - move the submenu's selection cursor. |
122 | | Navigate(NavigationDirection), |
123 | | /// Any other key while the submenu is open. |
124 | | NoOp, |
125 | | } |
126 | | |
127 | | /// Direction of a navigation keystroke inside the enable/disable |
128 | | /// submenu. |
129 | | #[derive(Debug, PartialEq, Eq, Clone, Copy)] |
130 | | enum NavigationDirection { |
131 | | Up, |
132 | | Down, |
133 | | Left, |
134 | | Right, |
135 | | } |
136 | | |
137 | | /// Classifies a top-level control-mode keystroke. |
138 | | /// |
139 | | /// `control_key_state` is ANDed with [`MODIFIER_MASK`] so lock |
140 | | /// toggles (`CAPSLOCK_ON`, `NUMLOCK_ON`, `SCROLLLOCK_ON`) and the |
141 | | /// `ENHANCED_KEY` flag never bleed into the match - the |
142 | | /// `(VK_*, 0)` arms must still fire while any of those bits are |
143 | | /// set. Any "real" modifier bit (Ctrl / Alt / Shift) survives the |
144 | | /// mask and falls through to [`ControlModeAction::NoOp`]. |
145 | | /// |
146 | | /// # Arguments |
147 | | /// |
148 | | /// * `virtual_key` - The pressed key's [`VIRTUAL_KEY`]. |
149 | | /// * `control_key_state` - The raw `dwControlKeyState` field from |
150 | | /// the [`KEY_EVENT_RECORD`]. |
151 | | /// |
152 | | /// # Returns |
153 | | /// |
154 | | /// The [`ControlModeAction`] the dispatch should execute. |
155 | 78 | fn classify_control_mode_key( |
156 | 78 | virtual_key: VIRTUAL_KEY, |
157 | 78 | control_key_state: u32, |
158 | 78 | ) -> ControlModeAction { |
159 | 78 | return match (virtual_key, control_key_state & MODIFIER_MASK) { |
160 | 6 | (VK_R, 0) => ControlModeAction::Retile, |
161 | 6 | (VK_E, 0) => ControlModeAction::OpenEnableDisableSubmenu, |
162 | 6 | (VK_T, 0) => ControlModeAction::ToggleEnabled, |
163 | 6 | (VK_N, 0) => ControlModeAction::EnableAll, |
164 | 6 | (VK_C, 0) => ControlModeAction::CreateWindows, |
165 | 6 | (VK_H, 0) => ControlModeAction::CopyHostnames, |
166 | 42 | _ => ControlModeAction::NoOp, |
167 | | }; |
168 | 78 | } |
169 | | |
170 | | /// Classifies an enable/disable-submenu keystroke. |
171 | | /// |
172 | | /// See [`classify_control_mode_key`] for the [`MODIFIER_MASK`] |
173 | | /// rationale; the same lock-state / `ENHANCED_KEY` masking applies |
174 | | /// to the submenu so its `[e]`, `[d]`, `[t]` bindings keep working |
175 | | /// regardless of lock state. |
176 | | /// |
177 | | /// # Arguments |
178 | | /// |
179 | | /// * `virtual_key` - The pressed key's [`VIRTUAL_KEY`]. |
180 | | /// * `control_key_state` - The raw `dwControlKeyState` field from |
181 | | /// the [`KEY_EVENT_RECORD`]. |
182 | | /// |
183 | | /// # Returns |
184 | | /// |
185 | | /// The [`EnableDisableSubmenuAction`] the dispatch should execute. |
186 | 154 | fn classify_enable_disable_submenu_key( |
187 | 154 | virtual_key: VIRTUAL_KEY, |
188 | 154 | control_key_state: u32, |
189 | 154 | ) -> EnableDisableSubmenuAction { |
190 | 154 | return match (virtual_key, control_key_state & MODIFIER_MASK) { |
191 | 10 | (VK_E, 0) => EnableDisableSubmenuAction::Enable, |
192 | 7 | (VK_D, 0) => EnableDisableSubmenuAction::Disable, |
193 | 8 | (VK_T, 0) => EnableDisableSubmenuAction::Toggle, |
194 | 13 | (VK_UP, 0) | (VK_K, 0) => EnableDisableSubmenuAction::Navigate(NavigationDirection::Up), |
195 | 14 | (VK_DOWN, 0) | (VK_J, 0) => EnableDisableSubmenuAction::Navigate(NavigationDirection::Down), |
196 | 12 | (VK_LEFT, 0) | (VK_H, 0) => EnableDisableSubmenuAction::Navigate(NavigationDirection::Left), |
197 | | (VK_RIGHT, 0) | (VK_L, 0) => { |
198 | 12 | EnableDisableSubmenuAction::Navigate(NavigationDirection::Right) |
199 | | } |
200 | 78 | _ => EnableDisableSubmenuAction::NoOp, |
201 | | }; |
202 | 154 | } |
203 | | |
204 | | /// Representation of a client |
205 | | #[derive(Clone)] |
206 | | struct Client { |
207 | | /// Hostname the client is connect to (or supposed to connect to). |
208 | | hostname: String, |
209 | | /// Window handle to the clients console window. |
210 | | window_handle: HWND, |
211 | | /// Process handle to the client process. |
212 | | process_handle: HANDLE, |
213 | | /// Process id of the client process. |
214 | | /// |
215 | | /// Used by the pipe server task to correlate which client has connected |
216 | | /// to it, via a handshake over the named pipe. |
217 | | process_id: u32, |
218 | | /// Authoritative source for this client's [`ClientState`]. |
219 | | /// |
220 | | /// The daemon broadcasts new state values through the [`watch::Sender`]; |
221 | | /// the assigned pipe-server task subscribes upon successful PID |
222 | | /// correlation and forwards every change to the client over the named |
223 | | /// pipe. [`watch::Sender`] is itself [`Clone`], so cloning a [`Client`] |
224 | | /// produces another sender that drives the same channel. |
225 | | state_sender: watch::Sender<ClientState>, |
226 | | /// Authoritative source for this client's highlight flag, set while |
227 | | /// the client is the daemon's currently selected submenu client. |
228 | | /// Visual only; input gating uses [`Client::state_sender`]. |
229 | | highlight_sender: watch::Sender<bool>, |
230 | | /// Index passed to [`arrange_client_window`] when this client's |
231 | | /// on-screen position was last computed. Survives |
232 | | /// [`Clients::retain`] so the submenu navigation grid keeps |
233 | | /// matching the visible layout until the next retile. |
234 | | tile_index: usize, |
235 | | } |
236 | | |
237 | | unsafe impl Send for Client {} |
238 | | |
239 | | /// Collection of [`Client`]s maintaining insertion order and a PID-indexed |
240 | | /// lookup table. |
241 | | /// |
242 | | /// The ordered list preserves client window placement semantics, while the |
243 | | /// index enables O(1) lookup by process id - required by the pipe server task |
244 | | /// during PID correlation and future per-client pipe server control. |
245 | | struct Clients { |
246 | | /// Ordered list of clients; order matches launch order and is used for |
247 | | /// window arrangement and z-order synchronization. |
248 | | list: Vec<Client>, |
249 | | /// Maps a client's process id to its index in [`list`](Clients::list). |
250 | | pid_index: HashMap<u32, usize>, |
251 | | /// `number_of_consoles` value the current on-screen layout was |
252 | | /// computed with. Drives [`grid_dimensions`] for the submenu nav. |
253 | | /// Updated when the tiler positions windows |
254 | | /// ([`Clients::reset_tile_layout`]); preserved across |
255 | | /// [`Clients::retain`] so a closed-but-not-retiled window leaves |
256 | | /// a visible gap in the grid too. |
257 | | layout_n: usize, |
258 | | } |
259 | | |
260 | | impl Clients { |
261 | | /// Creates a new empty collection. |
262 | 27 | fn new() -> Self { |
263 | 27 | return Clients { |
264 | 27 | list: Vec::new(), |
265 | 27 | pid_index: HashMap::new(), |
266 | 27 | layout_n: 0, |
267 | 27 | }; |
268 | 27 | } |
269 | | |
270 | | /// Appends a client to the collection and records its position in the |
271 | | /// PID index. |
272 | | /// |
273 | | /// # Arguments |
274 | | /// |
275 | | /// * `client` - The [`Client`] to add. |
276 | | /// |
277 | | /// # Panics |
278 | | /// |
279 | | /// Panics if a client with the same process id is already present, as |
280 | | /// duplicate PIDs indicate broken daemon bookkeeping. |
281 | 59 | fn push(&mut self, mut client: Client) { |
282 | 59 | let index = self.list.len(); |
283 | 59 | assert!( |
284 | 59 | !self.pid_index.contains_key(&client.process_id), |
285 | | "Duplicate client PID {} - daemon bookkeeping broken", |
286 | | client.process_id, |
287 | | ); |
288 | | // Push assumes the new client occupies the next cell in a dense |
289 | | // layout - matching what the tiler does at initial launch and |
290 | | // right after `[c]reate`. `retain` leaves these values alone so |
291 | | // closed-but-not-retiled gaps stay visible to the navigation |
292 | | // grid. The next retile renumbers everything dense again. |
293 | 58 | client.tile_index = index; |
294 | 58 | self.pid_index.insert(client.process_id, index); |
295 | 58 | self.list.push(client); |
296 | 58 | self.layout_n = self.list.len(); |
297 | 58 | } |
298 | | |
299 | | /// Reassigns dense [`Client::tile_index`] values to `valid_pids` in |
300 | | /// the supplied order and snapshots the new [`Clients::layout_n`]. |
301 | | /// |
302 | | /// Called by [`Daemon::rearrange_client_windows`] right before it |
303 | | /// re-positions the actual windows on screen, so navigation reads |
304 | | /// the same layout the tiler just applied. |
305 | | /// |
306 | | /// Invariant: `valid_pids` must cover every PID currently tracked |
307 | | /// in `self.list`. Passing a strict subset (e.g. a liveness-filtered |
308 | | /// list) would shrink `layout_n` while leaving stale `tile_index >= |
309 | | /// layout_n` values on the excluded clients, breaking the |
310 | | /// [`ClientGrid`] built from this collection. Drop dead clients |
311 | | /// via [`Clients::retain`] before retiling. |
312 | | /// |
313 | | /// # Arguments |
314 | | /// |
315 | | /// * `valid_pids` - PIDs of the clients that will be tiled, in the |
316 | | /// order they will be passed to |
317 | | /// [`arrange_client_window`]. |
318 | 1 | fn reset_tile_layout(&mut self, valid_pids: &[u32]) { |
319 | 1 | debug_assert_eq!( |
320 | 1 | valid_pids.len(), |
321 | 1 | self.list.len(), |
322 | | "reset_tile_layout must receive every tracked client; \ |
323 | | call Clients::retain to drop dead entries first", |
324 | | ); |
325 | 4 | for (index, pid) in valid_pids1 .iter1 ().enumerate1 () { |
326 | 4 | if let Some(&list_index) = self.pid_index.get(pid) { |
327 | 4 | self.list[list_index].tile_index = index; |
328 | 4 | }0 |
329 | | } |
330 | 1 | self.layout_n = valid_pids.len(); |
331 | 1 | } |
332 | | |
333 | | /// Returns a reference to the client with the given process id, if any. |
334 | | /// |
335 | | /// # Arguments |
336 | | /// |
337 | | /// * `pid` - The process id of the client to look up. |
338 | | /// |
339 | | /// # Returns |
340 | | /// |
341 | | /// `Some(&Client)` if a client with the given PID exists, `None` otherwise. |
342 | 39 | fn get_by_pid(&self, pid: u32) -> Option<&Client> { |
343 | 39 | return self |
344 | 39 | .pid_index |
345 | 39 | .get(&pid) |
346 | 39 | .map(|&index| return &self.list[index]34 ); |
347 | 39 | } |
348 | | |
349 | | /// Retains only the clients for which the predicate returns `true`, |
350 | | /// rebuilding the PID index to reflect the new positions. |
351 | | /// |
352 | | /// # Arguments |
353 | | /// |
354 | | /// * `f` - Predicate applied to each [`Client`]; kept when it returns `true`. |
355 | 5 | fn retain<F: FnMut(&Client) -> bool>(&mut self, mut f: F) { |
356 | 17 | self.list5 .retain5 (|client| return f(client)); |
357 | 5 | self.pid_index.clear(); |
358 | 10 | for (index, client) in self.list.iter()5 .enumerate5 () { |
359 | 10 | self.pid_index.insert(client.process_id, index); |
360 | 10 | } |
361 | 5 | } |
362 | | } |
363 | | |
364 | | /// Allows treating a [`Clients`] collection as a `&[Client]`, so callers can |
365 | | /// use `&clients` where a slice is expected and get slice methods |
366 | | /// (`iter`, `len`, `is_empty`, ...) via deref coercion. |
367 | | impl std::ops::Deref for Clients { |
368 | | type Target = [Client]; |
369 | | |
370 | 46 | fn deref(&self) -> &[Client] { |
371 | 46 | return &self.list; |
372 | 46 | } |
373 | | } |
374 | | |
375 | | /// Consumes the collection and yields its clients in insertion order. |
376 | | /// |
377 | | /// Used when merging a freshly launched [`Clients`] batch into an existing |
378 | | /// collection while also spawning per-client pipe servers. |
379 | | impl IntoIterator for Clients { |
380 | | type Item = Client; |
381 | | type IntoIter = std::vec::IntoIter<Client>; |
382 | | |
383 | 0 | fn into_iter(self) -> Self::IntoIter { |
384 | 0 | return self.list.into_iter(); |
385 | 0 | } |
386 | | } |
387 | | |
388 | | /// Hacky wrapper around a window handle. |
389 | | /// |
390 | | /// As we cannot implement foreign traits for foreign structs |
391 | | /// we introduce this wrapper to implement [Send] for [HWND]. |
392 | | #[derive(Debug, Eq)] |
393 | | struct HWNDWrapper { |
394 | | hwdn: HWND, |
395 | | } |
396 | | |
397 | | unsafe impl Send for HWNDWrapper {} |
398 | | |
399 | | impl PartialEq for HWNDWrapper { |
400 | | /// Returns whether to `HWNDWrapper` instances are equal or not |
401 | | /// based on the [HWND] they wrap. |
402 | 2 | fn eq(&self, other: &Self) -> bool { |
403 | 2 | return self.hwdn == other.hwdn; |
404 | 2 | } |
405 | | } |
406 | | |
407 | | /// Returns a window handle to the current console window. |
408 | | /// |
409 | | /// The [HWND] is wrapped in a `HWNDWrapper` so that |
410 | | /// we can pass it inbetween threads. |
411 | 0 | fn get_console_window_wrapper(api: &dyn WindowsApi) -> HWNDWrapper { |
412 | 0 | return HWNDWrapper { |
413 | 0 | hwdn: api.get_console_window(), |
414 | 0 | }; |
415 | 0 | } |
416 | | |
417 | | /// Returns a window handle to the foreground window. |
418 | | /// |
419 | | /// The [HWND] is wrapped in a `HWNDWrapper` so that |
420 | | /// we can pass it inbetween threads. |
421 | 0 | fn get_foreground_window_wrapper(api: &dyn WindowsApi) -> HWNDWrapper { |
422 | 0 | return HWNDWrapper { |
423 | 0 | hwdn: api.get_foreground_window(), |
424 | 0 | }; |
425 | 0 | } |
426 | | |
427 | | /// Enum of all possible control mode states. |
428 | | #[derive(PartialEq, Debug)] |
429 | | enum ControlModeState { |
430 | | /// Controle mode is inactive. |
431 | | Inactive, |
432 | | /// One of the keys required for the control mode key combination |
433 | | /// is currently being pressed. |
434 | | Initiated, |
435 | | /// All required keys for the control mode key combination were pressed |
436 | | /// and control mode is now active. |
437 | | /// |
438 | | /// Active control mode prevents any input records from being sent to clients. |
439 | | Active, |
440 | | /// The user opened the `[e]nable/disable input` submenu from |
441 | | /// [`ControlModeState::Active`]; left only via `Esc`, which exits |
442 | | /// control mode entirely. `highlighted_pid` is the currently selected |
443 | | /// client (`None` when the cluster is empty); tracking by PID survives |
444 | | /// background-monitor `retain`s while the submenu is open. |
445 | | /// |
446 | | /// `anchor_col` is the upper-grid column carried across vertical |
447 | | /// moves so a Down + Up roundtrip across the partial-last-row |
448 | | /// boundary returns to the start cell. `None` while |
449 | | /// `highlighted_pid` is `None`. |
450 | | EnableDisableSubmenu { |
451 | | /// PID of the highlighted client, or `None` for an empty cluster. |
452 | | highlighted_pid: Option<u32>, |
453 | | /// Anchor upper-grid column carried across vertical moves. |
454 | | anchor_col: Option<i32>, |
455 | | }, |
456 | | } |
457 | | |
458 | | /// The daemon is responsible to launch a client for |
459 | | /// each host, positioning the client windows, forwarding |
460 | | /// input records to all clients and handling control mode. |
461 | | struct Daemon<'a> { |
462 | | /// A list of hostnames to connect to. |
463 | | hosts: Vec<String>, |
464 | | /// A username to use to connect to all clients. |
465 | | /// |
466 | | /// If it is empty the clients will use the SSH config to find an approriate |
467 | | /// username. |
468 | | username: Option<String>, |
469 | | /// Optional port used for all SSH connections. |
470 | | port: Option<u16>, |
471 | | /// The `DaemonConfig` that controls how the daemon console window looks like. |
472 | | config: &'a DaemonConfig, |
473 | | /// List of available cluster tags |
474 | | clusters: &'a [Cluster], |
475 | | /// The current control mode state. The submenu's selected client |
476 | | /// is carried inline on |
477 | | /// [`ControlModeState::EnableDisableSubmenu`] - tying its |
478 | | /// lifetime to the variant guarantees no stale highlight survives |
479 | | /// after `Esc`. |
480 | | control_mode_state: ControlModeState, |
481 | | /// If debug mode is enabled on the daemon it will also be enabled on all |
482 | | /// clients. |
483 | | debug: bool, |
484 | | } |
485 | | |
486 | | /// Compute the next submenu selection given a grid step. |
487 | | /// |
488 | | /// Re-anchors on the first surviving client when `current_pid` is no |
489 | | /// longer present (retained out while the submenu was open). |
490 | | /// |
491 | | /// # Arguments |
492 | | /// |
493 | | /// * `grid` - Spatial grid view over the currently tracked clients. |
494 | | /// * `current_pid` - PID currently highlighted, or `None`. |
495 | | /// * `anchor_col` - Anchor column carried from earlier moves. |
496 | | /// * `direction` - Direction the navigation keystroke encoded. |
497 | | /// * `edge` - Behavior when the move would leave the grid. |
498 | | /// |
499 | | /// # Returns |
500 | | /// |
501 | | /// `(new_pid, new_anchor_col)` to apply, or `(None, None)` for an empty |
502 | | /// cluster. |
503 | 33 | fn next_submenu_selection( |
504 | 33 | grid: &ClientGrid, |
505 | 33 | current_pid: Option<u32>, |
506 | 33 | anchor_col: Option<i32>, |
507 | 33 | direction: NavigationDirection, |
508 | 33 | edge: EdgeBehavior, |
509 | 33 | ) -> (Option<u32>, Option<i32>) { |
510 | 33 | if grid.is_empty() { |
511 | 8 | return (None, None); |
512 | 25 | } |
513 | 25 | let current_pid23 = match current_pid.and_then(|pid| return grid.cell(pid)) { |
514 | 23 | Some(cell) => cell.pid, |
515 | | None => { |
516 | 2 | let first = grid.top_left_pid(); |
517 | 2 | let first_anchor = first |
518 | 2 | .and_then(|pid| return grid.cell(pid)) |
519 | 2 | .map(|c| return grid.anchor_for(c)); |
520 | 2 | return (first, first_anchor); |
521 | | } |
522 | | }; |
523 | 23 | let anchor = anchor_col.unwrap_or_else(|| {0 |
524 | 0 | return grid |
525 | 0 | .cell(current_pid) |
526 | 0 | .map(|c| return grid.anchor_for(c)) |
527 | 0 | .unwrap_or(0); |
528 | 0 | }); |
529 | 23 | return match grid.step(current_pid, anchor, direction, edge) { |
530 | 23 | Some((pid, new_anchor)) => (Some(pid), Some(new_anchor)), |
531 | 0 | None => (Some(current_pid), Some(anchor)), |
532 | | }; |
533 | 33 | } |
534 | | |
535 | | /// Build a [`ClientGrid`] from `clients` and `workspace_area` using the |
536 | | /// same aspect-ratio expression the tiler uses. |
537 | | /// |
538 | | /// # Arguments |
539 | | /// |
540 | | /// * `clients` - Currently tracked clients in launch order. |
541 | | /// * `workspace_area` - Available workspace minus the daemon console. |
542 | | /// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon config. |
543 | | /// |
544 | | /// # Returns |
545 | | /// |
546 | | /// A populated [`ClientGrid`]. |
547 | 3 | fn build_client_grid( |
548 | 3 | clients: &Clients, |
549 | 3 | workspace_area: &workspace::WorkspaceArea, |
550 | 3 | aspect_ratio_adjustment: f64, |
551 | 3 | ) -> ClientGrid { |
552 | 3 | let aspect = workspace_aspect_ratio(workspace_area); |
553 | 3 | let layout_n = clients.layout_n as i32; |
554 | 3 | let (cols, rows) = grid_dimensions(layout_n, aspect, aspect_ratio_adjustment); |
555 | 3 | let cells: Vec<(u32, usize)> = clients |
556 | 3 | .iter() |
557 | 7 | .map3 (|c| return (c.process_id, c.tile_index)) |
558 | 3 | .collect(); |
559 | 3 | return ClientGrid::from_tiled_pids(&cells, layout_n, cols, rows); |
560 | 3 | } |
561 | | |
562 | | impl<'a> Daemon<'a> { |
563 | | /// Builds a minimal [`Daemon`] suitable for unit tests. |
564 | | /// |
565 | | /// Populates every field with defaults that do not touch the |
566 | | /// Windows API or the network. Tests pick the |
567 | | /// [`ControlModeState`] they need to exercise; everything else |
568 | | /// stays inert. |
569 | | #[cfg(test)] |
570 | 16 | fn for_test( |
571 | 16 | config: &'a DaemonConfig, |
572 | 16 | clusters: &'a [Cluster], |
573 | 16 | control_mode_state: ControlModeState, |
574 | 16 | ) -> Self { |
575 | 16 | return Self { |
576 | 16 | hosts: Vec::new(), |
577 | 16 | username: None, |
578 | 16 | port: None, |
579 | 16 | config, |
580 | 16 | clusters, |
581 | 16 | control_mode_state, |
582 | 16 | debug: false, |
583 | 16 | }; |
584 | 16 | } |
585 | | |
586 | | /// Launches all client windows and blocks on the main run loop. |
587 | | /// |
588 | | /// Sets up the daemon console by disabling processed input mode and applying |
589 | | /// the configured colors and dimensions. |
590 | | /// Once all client windows have successfully started the daemon console window |
591 | | /// is moved to the foreground and receives focus. |
592 | 0 | async fn launch<W: WindowsApi + Clone + 'static>(mut self, windows_api: &W) { |
593 | 0 | windows_api |
594 | 0 | .set_console_title(format!("{PKG_NAME} daemon").as_str()) |
595 | 0 | .unwrap(); |
596 | 0 | set_console_color( |
597 | 0 | windows_api, |
598 | 0 | CONSOLE_CHARACTER_ATTRIBUTES(self.config.console_color), |
599 | | ); |
600 | 0 | set_console_border_color(windows_api, COLORREF(0x000000FF)); |
601 | | |
602 | 0 | toggle_processed_input_mode(windows_api); // Disable processed input mode |
603 | | |
604 | 0 | let workspace_area = workspace::get_workspace_area(windows_api, self.config.height); |
605 | | |
606 | 0 | self.arrange_daemon_console(windows_api, &workspace_area); |
607 | | |
608 | | // Looks like on windows 10 re-arranging the console resets the console output buffer |
609 | 0 | set_console_color( |
610 | 0 | windows_api, |
611 | 0 | CONSOLE_CHARACTER_ATTRIBUTES(self.config.console_color), |
612 | | ); |
613 | | |
614 | 0 | let mut clients = Arc::new(Mutex::new( |
615 | 0 | launch_clients( |
616 | 0 | windows_api, |
617 | 0 | self.hosts.to_vec(), |
618 | 0 | &self.username, |
619 | 0 | self.port, |
620 | 0 | self.debug, |
621 | 0 | &workspace_area, |
622 | 0 | self.config.aspect_ratio_adjustment, |
623 | 0 | 0, |
624 | 0 | ) |
625 | 0 | .await, |
626 | | )); |
627 | | |
628 | | // Now that all clients started, focus the daemon console again. |
629 | 0 | let daemon_console = windows_api.get_console_window(); |
630 | 0 | let _ = windows_api.bring_window_to_top(daemon_console, true); |
631 | | |
632 | 0 | self.print_instructions(windows_api); |
633 | 0 | self.run(windows_api, &mut clients, &workspace_area).await; |
634 | 0 | } |
635 | | |
636 | | /// The main run loop of the `daemon` subcommand. |
637 | | /// |
638 | | /// Opens a multi-producer, multi-consumer broadcasting channel used to |
639 | | /// send the read input records in parallel to the name pipe servers |
640 | | /// the clients are listening on. |
641 | | /// Spawns a background thread that waits for all clients to terminate |
642 | | /// and then stops the current process. |
643 | | /// Spawns a background thread that ensures the z-order of all client |
644 | | /// windows is in sync with the daemon window. |
645 | | /// I.e. if the daemon window is focussed, all clients should be moved to the foreground. |
646 | | /// |
647 | | /// The main loop consists of waiting for input records to read from the keyboard, |
648 | | /// sending them to all clients and handling control mode. |
649 | | /// |
650 | | /// # Arguments |
651 | | /// |
652 | | /// * `windows_api` - The Windows API implementation to use |
653 | | /// * `clients` - A thread safe mapping from the number |
654 | | /// a client console window was launched at |
655 | | /// in relation to the other client windows |
656 | | /// and the clients console window handle. |
657 | | /// * `workspace_area` - The available workspace area on the |
658 | | /// primary monitor minus the space occupied |
659 | | /// by the daemon console window. |
660 | 0 | async fn run<W: WindowsApi + Clone + 'static>( |
661 | 0 | &mut self, |
662 | 0 | windows_api: &W, |
663 | 0 | clients: &mut Arc<Mutex<Clients>>, |
664 | 0 | workspace_area: &workspace::WorkspaceArea, |
665 | 0 | ) { |
666 | 0 | let (sender, _) = |
667 | 0 | broadcast::channel::<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>(SENDER_CAPACITY); |
668 | | |
669 | 0 | let mut servers = Arc::new(Mutex::new( |
670 | 0 | self.launch_named_pipe_servers(&sender, Arc::clone(clients)), |
671 | | )); |
672 | | |
673 | | // Monitor client processes |
674 | 0 | let clients_clone = Arc::clone(clients); |
675 | 0 | let windows_api_clone = windows_api.clone(); |
676 | 0 | tokio::spawn(async move { |
677 | | loop { |
678 | 0 | clients_clone.lock().unwrap().retain(|client| { |
679 | 0 | match windows_api_clone.get_exit_code(client.process_handle) { |
680 | 0 | Ok(exit_code) => return exit_code == STILL_ACTIVE.0 as u32, |
681 | 0 | Err(_) => return false, // Process handle is invalid, remove client |
682 | | } |
683 | 0 | }); |
684 | 0 | if clients_clone.lock().unwrap().is_empty() { |
685 | | // All clients have exited, exit the daemon as well |
686 | 0 | std::process::exit(0); |
687 | 0 | } |
688 | 0 | tokio::time::sleep(Duration::from_millis(5)).await; |
689 | | } |
690 | | }); |
691 | | |
692 | 0 | ensure_client_z_order_in_sync_with_daemon( |
693 | 0 | Arc::new(windows_api.clone()), |
694 | 0 | clients.to_owned(), |
695 | | ); |
696 | | |
697 | | loop { |
698 | 0 | self.handle_input_record( |
699 | 0 | windows_api, |
700 | 0 | &sender, |
701 | 0 | read_keyboard_input(windows_api), |
702 | 0 | clients, |
703 | 0 | workspace_area, |
704 | 0 | &mut servers, |
705 | 0 | ) |
706 | 0 | .await; |
707 | | } |
708 | | } |
709 | | |
710 | | /// Launch a named pipe server for each host in a dedicated thread. |
711 | | /// |
712 | | /// # Arguments |
713 | | /// |
714 | | /// * `sender` - The sender end of the broadcast channel through which |
715 | | /// the main thread will send the input records that are to |
716 | | /// be forwarded to the clients. |
717 | | /// |
718 | | /// # Returns |
719 | | /// |
720 | | /// Returns a list of [JoinHandle]s, one handle for each thread. |
721 | 0 | fn launch_named_pipe_servers( |
722 | 0 | &self, |
723 | 0 | sender: &Sender<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>, |
724 | 0 | clients: Arc<Mutex<Clients>>, |
725 | 0 | ) -> Vec<JoinHandle<()>> { |
726 | 0 | let mut servers: Vec<JoinHandle<()>> = Vec::new(); |
727 | 0 | for _ in &self.hosts { |
728 | 0 | self.launch_named_pipe_server(&mut servers, sender, Arc::clone(&clients)); |
729 | 0 | } |
730 | 0 | return servers; |
731 | 0 | } |
732 | | |
733 | | /// Launch a named pipe server in a dedicated thread. |
734 | | /// |
735 | | /// # Arguments |
736 | | /// |
737 | | /// * `servers` - A list of [JoinHandle]s to which the join handle for |
738 | | /// the new thread will be added. |
739 | | /// * `sender` - The sender end of the broadcast channel through which |
740 | | /// the main thread will send the input records that are to |
741 | | /// be forwarded to the clients. |
742 | 0 | fn launch_named_pipe_server( |
743 | 0 | &self, |
744 | 0 | servers: &mut Vec<JoinHandle<()>>, |
745 | 0 | sender: &Sender<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>, |
746 | 0 | clients: Arc<Mutex<Clients>>, |
747 | 0 | ) { |
748 | 0 | let named_pipe_server = ServerOptions::new() |
749 | 0 | .access_inbound(true) |
750 | 0 | .access_outbound(true) |
751 | 0 | .pipe_mode(PipeMode::Message) |
752 | 0 | .create(PIPE_NAME) |
753 | 0 | .unwrap_or_else(|err| { |
754 | 0 | error!("{}", err); |
755 | 0 | panic!("Failed to create named pipe server",) |
756 | | }); |
757 | 0 | let mut receiver = sender.subscribe(); |
758 | 0 | servers.push(tokio::spawn(async move { |
759 | 0 | named_pipe_server_routine(named_pipe_server, &mut receiver, clients).await; |
760 | 0 | })); |
761 | 0 | } |
762 | | |
763 | | /// Handle the given input record. |
764 | | /// |
765 | | /// Input records are being forwarded to all clients. |
766 | | /// If a sequence of input records matches the control mode |
767 | | /// key combination, forwarding is temporarily interrupted, |
768 | | /// until control mode is exited. |
769 | | /// |
770 | | /// # Arguments |
771 | | /// |
772 | | /// * `sender` - The sender end of the broadcast channel |
773 | | /// through which we will send the input records |
774 | | /// that are being forwarded to the clients |
775 | | /// by the named pipe servers (`servers`). |
776 | | /// * `input_record` - The [INPUT_RECORD_0].`KeyEvent` read from the |
777 | | /// console input buffer. |
778 | | /// * `clients` - A thread safe mapping from the number |
779 | | /// a client console window was launched at |
780 | | /// in relation to the other client windows |
781 | | /// and the clients console window handle. |
782 | | /// The mapping will be extended if additional clients |
783 | | /// are being added through control mode `[c]reate window(s)`. |
784 | | /// * `workspace_area` - The available workspace area on the |
785 | | /// primary monitor minus the space occupied |
786 | | /// by the daemon console window. |
787 | | /// * `servers` - A thread safe list of [JoinHandle]s, |
788 | | /// one handle for each named pipe server background thread. |
789 | | /// The list will be extended if additional clients are being added |
790 | | /// through control mode `[c]reate window(s)`. |
791 | 0 | async fn handle_input_record<W: WindowsApi + Clone + 'static>( |
792 | 0 | &mut self, |
793 | 0 | windows_api: &W, |
794 | 0 | sender: &Sender<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>, |
795 | 0 | input_record: INPUT_RECORD_0, |
796 | 0 | clients: &mut Arc<Mutex<Clients>>, |
797 | 0 | workspace_area: &workspace::WorkspaceArea, |
798 | 0 | servers: &mut Arc<Mutex<Vec<JoinHandle<()>>>>, |
799 | 0 | ) { |
800 | 0 | if self.control_mode_is_active(windows_api, clients, input_record) { |
801 | 0 | if self.control_mode_state == ControlModeState::Initiated { |
802 | 0 | clear_screen(windows_api); |
803 | 0 | println!("Control Mode (Esc to exit)"); |
804 | 0 | println!( |
805 | | "[c]reate window(s), [r]etile, [e]nable/disable input, [t]oggle enabled, e[n]able all, copy active [h]ostname(s)" |
806 | | ); |
807 | 0 | self.control_mode_state = ControlModeState::Active; |
808 | 0 | return; |
809 | 0 | } |
810 | 0 | let key_event = unsafe { input_record.KeyEvent }; |
811 | 0 | if !key_event.bKeyDown.as_bool() { |
812 | 0 | return; |
813 | 0 | } |
814 | 0 | if matches!( |
815 | 0 | self.control_mode_state, |
816 | | ControlModeState::EnableDisableSubmenu { .. } |
817 | | ) { |
818 | 0 | self.handle_enable_disable_submenu_key( |
819 | 0 | windows_api, |
820 | 0 | clients, |
821 | 0 | workspace_area, |
822 | 0 | key_event, |
823 | | ); |
824 | 0 | return; |
825 | 0 | } |
826 | 0 | match classify_control_mode_key( |
827 | 0 | VIRTUAL_KEY(key_event.wVirtualKeyCode), |
828 | 0 | key_event.dwControlKeyState, |
829 | 0 | ) { |
830 | 0 | ControlModeAction::Retile => { |
831 | 0 | self.rearrange_client_windows( |
832 | 0 | windows_api, |
833 | 0 | &mut clients.lock().unwrap(), |
834 | 0 | workspace_area, |
835 | 0 | ); |
836 | 0 | self.arrange_daemon_console(windows_api, workspace_area); |
837 | 0 | } |
838 | | ControlModeAction::OpenEnableDisableSubmenu => { |
839 | 0 | let clients_guard = clients.lock().unwrap(); |
840 | 0 | let grid = build_client_grid( |
841 | 0 | &clients_guard, |
842 | 0 | workspace_area, |
843 | 0 | self.config.aspect_ratio_adjustment, |
844 | | ); |
845 | 0 | let next_pid = grid.top_left_pid(); |
846 | 0 | let anchor_col = next_pid |
847 | 0 | .and_then(|p| return grid.cell(p)) |
848 | 0 | .map(|c| return grid.anchor_for(c)); |
849 | 0 | self.apply_submenu_highlight(&clients_guard, None, next_pid); |
850 | 0 | self.control_mode_state = ControlModeState::EnableDisableSubmenu { |
851 | 0 | highlighted_pid: next_pid, |
852 | 0 | anchor_col, |
853 | 0 | }; |
854 | 0 | self.render_enable_disable_submenu(windows_api); |
855 | | } |
856 | | ControlModeAction::ToggleEnabled => { |
857 | | // Snapshot before flipping so each client toggles relative |
858 | | // to its own pre-loop state, not to writes this loop has |
859 | | // already made. |
860 | 0 | self.update_client_states(clients, |clients_guard| { |
861 | 0 | return clients_guard |
862 | 0 | .iter() |
863 | 0 | .map(|client| { |
864 | 0 | let flipped = match *client.state_sender.borrow() { |
865 | 0 | ClientState::Active => ClientState::Disabled, |
866 | 0 | ClientState::Disabled => ClientState::Active, |
867 | | }; |
868 | 0 | return (client.process_id, flipped); |
869 | 0 | }) |
870 | 0 | .collect(); |
871 | 0 | }); |
872 | 0 | self.quit_control_mode(windows_api); |
873 | | } |
874 | | ControlModeAction::EnableAll => { |
875 | 0 | self.update_client_states(clients, |clients_guard| { |
876 | 0 | return clients_guard |
877 | 0 | .iter() |
878 | 0 | .map(|client| return (client.process_id, ClientState::Active)) |
879 | 0 | .collect(); |
880 | 0 | }); |
881 | 0 | self.quit_control_mode(windows_api); |
882 | | } |
883 | | ControlModeAction::CreateWindows => { |
884 | 0 | clear_screen(windows_api); |
885 | | // TODO: make ESC abort |
886 | 0 | println!("Hostname(s) or cluster tag(s): (leave empty to abort)"); |
887 | 0 | toggle_processed_input_mode(windows_api); // As it was disabled before, this enables it again |
888 | 0 | let mut hostnames = String::new(); |
889 | 0 | match io::stdin().read_line(&mut hostnames) { |
890 | 0 | Ok(2) => { |
891 | 0 | // Empty input (only newline '\n') |
892 | 0 | } |
893 | | Ok(_) => { |
894 | 0 | let number_of_existing_clients = clients.lock().unwrap().len(); |
895 | 0 | let new_clients = launch_clients( |
896 | 0 | windows_api, |
897 | 0 | expand_hosts( |
898 | 0 | hostnames.split(' ').map(|x| return x.trim()).collect(), |
899 | 0 | self.clusters, |
900 | | ), |
901 | 0 | &self.username, |
902 | 0 | self.port, |
903 | 0 | self.debug, |
904 | 0 | workspace_area, |
905 | 0 | self.config.aspect_ratio_adjustment, |
906 | 0 | number_of_existing_clients, |
907 | | ) |
908 | 0 | .await; |
909 | 0 | for client in new_clients.into_iter() { |
910 | 0 | clients.lock().unwrap().push(client); |
911 | 0 | self.launch_named_pipe_server( |
912 | 0 | &mut servers.lock().unwrap(), |
913 | 0 | sender, |
914 | 0 | Arc::clone(clients), |
915 | 0 | ); |
916 | 0 | } |
917 | | } |
918 | 0 | Err(error) => { |
919 | 0 | error!("{error}"); |
920 | | } |
921 | | } |
922 | 0 | toggle_processed_input_mode(windows_api); // Re-disable processed input mode. |
923 | 0 | self.rearrange_client_windows( |
924 | 0 | windows_api, |
925 | 0 | &mut clients.lock().unwrap(), |
926 | 0 | workspace_area, |
927 | | ); |
928 | 0 | self.arrange_daemon_console(windows_api, workspace_area); |
929 | | // Focus the daemon console again. |
930 | 0 | let daemon_window = windows_api.get_console_window(); |
931 | 0 | let _ = windows_api.bring_window_to_top(daemon_window, true); |
932 | 0 | self.quit_control_mode(windows_api); |
933 | | } |
934 | | ControlModeAction::CopyHostnames => { |
935 | 0 | let mut active_hostnames: Vec<String> = vec![]; |
936 | 0 | for client in clients.lock().unwrap().iter() { |
937 | 0 | if windows_api.is_window(client.window_handle) { |
938 | 0 | active_hostnames.push(client.hostname.clone()); |
939 | 0 | } |
940 | | } |
941 | 0 | cli_clipboard::set_contents(active_hostnames.join(" ")).unwrap(); |
942 | 0 | self.quit_control_mode(windows_api); |
943 | | } |
944 | 0 | ControlModeAction::NoOp => {} |
945 | | } |
946 | 0 | return; |
947 | 0 | } |
948 | 0 | let error_handler = |err| { |
949 | 0 | error!("{}", err); |
950 | 0 | panic!( |
951 | | "Failed to serialize input recored `{}`", |
952 | 0 | input_record.string_repr() |
953 | | ) |
954 | | }; |
955 | 0 | match sender.send( |
956 | 0 | serialize_input_record_0(&input_record)[..] |
957 | 0 | .try_into() |
958 | 0 | .unwrap_or_else(error_handler), |
959 | 0 | ) { |
960 | 0 | Ok(_) => {} |
961 | 0 | Err(_) => { |
962 | 0 | thread::sleep(time::Duration::from_nanos(1)); |
963 | 0 | } |
964 | | } |
965 | 0 | } |
966 | | |
967 | | /// Updates `self.control_mode_state` for the given input record and |
968 | | /// reports whether control mode owned the keystroke. |
969 | | /// |
970 | | /// Entering control mode requires this function to be called twice |
971 | | /// because the activating chord `Ctrl + A` produces two input |
972 | | /// records (the modifier press and the `A` key). Once active, every |
973 | | /// subsequent key - including the `Esc` that exits control mode - |
974 | | /// is reported as consumed so callers do not forward it to clients. |
975 | | /// |
976 | | /// # Arguments |
977 | | /// |
978 | | /// * `windows_api` - The Windows API implementation to use. |
979 | | /// * `clients` - Currently tracked clients. Used to clear the |
980 | | /// submenu highlight on the previously-selected |
981 | | /// client when `Esc` exits the enable/disable |
982 | | /// submenu. |
983 | | /// * `input_record` - A KeyEvent input record. |
984 | | /// |
985 | | /// # Returns |
986 | | /// |
987 | | /// Whether the input record was consumed by control mode. Returns |
988 | | /// `true` while control mode is active (including the `Esc` |
989 | | /// keystroke that exits it), so callers must not forward such |
990 | | /// records to clients. |
991 | 1 | fn control_mode_is_active<W: WindowsApi>( |
992 | 1 | &mut self, |
993 | 1 | windows_api: &W, |
994 | 1 | clients: &Mutex<Clients>, |
995 | 1 | input_record: INPUT_RECORD_0, |
996 | 1 | ) -> bool { |
997 | 1 | let key_event = unsafe { input_record.KeyEvent }; |
998 | 1 | if self.control_mode_state == ControlModeState::Active |
999 | 0 | || matches!( |
1000 | 0 | self.control_mode_state, |
1001 | | ControlModeState::EnableDisableSubmenu { .. } |
1002 | | ) |
1003 | | { |
1004 | 1 | if key_event.wVirtualKeyCode == VK_ESCAPE.0 { |
1005 | | if let ControlModeState::EnableDisableSubmenu { |
1006 | 0 | highlighted_pid, .. |
1007 | 1 | } = self.control_mode_state |
1008 | 0 | { |
1009 | 0 | let clients_guard = clients.lock().unwrap(); |
1010 | 0 | self.apply_submenu_highlight(&clients_guard, highlighted_pid, None); |
1011 | 1 | } |
1012 | 1 | self.quit_control_mode(windows_api); |
1013 | 1 | return true; |
1014 | 0 | } |
1015 | 0 | return true; |
1016 | 0 | } |
1017 | 0 | if (key_event.dwControlKeyState & LEFT_CTRL_PRESSED >= 1 |
1018 | 0 | || key_event.dwControlKeyState & RIGHT_CTRL_PRESSED >= 1) |
1019 | 0 | && key_event.wVirtualKeyCode == VK_A.0 |
1020 | | { |
1021 | 0 | self.control_mode_state = ControlModeState::Initiated; |
1022 | 0 | return true; |
1023 | 0 | } |
1024 | 0 | return false; |
1025 | 1 | } |
1026 | | |
1027 | | /// Prints the default daemon instructions to the daemon console. |
1028 | | /// |
1029 | | /// # Arguments |
1030 | | /// |
1031 | | /// * `windows_api` - Windows API used to clear and redraw the |
1032 | | /// daemon console. |
1033 | 2 | fn quit_control_mode<W: WindowsApi>(&mut self, windows_api: &W) { |
1034 | 2 | self.print_instructions(windows_api); |
1035 | 2 | self.control_mode_state = ControlModeState::Inactive; |
1036 | 2 | } |
1037 | | |
1038 | | /// Clears the console screen and prints the default daemon instructions. |
1039 | 2 | fn print_instructions<W: WindowsApi>(&self, windows_api: &W) { |
1040 | 2 | clear_screen(windows_api); |
1041 | 2 | println!("Input to terminal: (Ctrl-A to enter control mode)"); |
1042 | 2 | } |
1043 | | |
1044 | | /// Iterates over all still open client windows and re-arranges them |
1045 | | /// on the screen based on the aspect ration adjustment daemon configuration. |
1046 | | /// |
1047 | | /// Client windows will be re-sized and re-positioned. |
1048 | | /// |
1049 | | /// # Arguments |
1050 | | /// |
1051 | | /// * `windows_api` - The Windows API implementation to use |
1052 | | /// * `clients` - A thread safe mapping from the number |
1053 | | /// a client console window was launched at |
1054 | | /// in relation to the other client windows |
1055 | | /// and the clients console window handle. |
1056 | | /// The number is relevant to determine the |
1057 | | /// position on the screen the window should |
1058 | | /// be placed at. |
1059 | | /// * `workspace_area` - The available workspace area on the |
1060 | | /// primary monitor minus the space occupied |
1061 | | /// by the daemon console window. |
1062 | 0 | fn rearrange_client_windows<W: WindowsApi>( |
1063 | 0 | &self, |
1064 | 0 | windows_api: &W, |
1065 | 0 | clients: &mut Clients, |
1066 | 0 | workspace_area: &workspace::WorkspaceArea, |
1067 | 0 | ) { |
1068 | 0 | clients.retain(|client| { |
1069 | 0 | let exit_code = match windows_api.get_exit_code(client.process_handle) { |
1070 | 0 | Ok(code) => code, |
1071 | 0 | Err(_) => return false, |
1072 | | }; |
1073 | 0 | return exit_code == STILL_ACTIVE.0 as u32 |
1074 | 0 | && windows_api.is_window(client.window_handle); |
1075 | 0 | }); |
1076 | 0 | let valid_layout: Vec<(u32, HWND)> = clients |
1077 | 0 | .iter() |
1078 | 0 | .map(|c| return (c.process_id, c.window_handle)) |
1079 | 0 | .collect(); |
1080 | 0 | let valid_pids: Vec<u32> = valid_layout.iter().map(|(pid, _)| return *pid).collect(); |
1081 | 0 | clients.reset_tile_layout(&valid_pids); |
1082 | 0 | for (index, (_, window_handle)) in valid_layout.iter().enumerate() { |
1083 | 0 | arrange_client_window( |
1084 | 0 | windows_api, |
1085 | 0 | window_handle, |
1086 | 0 | workspace_area, |
1087 | 0 | index, |
1088 | 0 | valid_layout.len(), |
1089 | 0 | self.config.aspect_ratio_adjustment, |
1090 | | ) |
1091 | | } |
1092 | 0 | } |
1093 | | |
1094 | | /// Dispatches a key press received while the daemon is in the |
1095 | | /// [`ControlModeState::EnableDisableSubmenu`] state. `[e]/[d]/[t]` |
1096 | | /// act on the currently selected client; `Navigate` moves the |
1097 | | /// selection and redraws the prompt. The submenu is left via |
1098 | | /// `ESC`, which is handled by the caller. |
1099 | | /// |
1100 | | /// # Arguments |
1101 | | /// |
1102 | | /// * `windows_api` - Windows API implementation used by the |
1103 | | /// render helper when redrawing after navigation. |
1104 | | /// * `clients` - Shared client collection. Empty lists are a |
1105 | | /// no-op for every action. |
1106 | | /// * `key_event` - The key-down [`KEY_EVENT_RECORD`] dispatched |
1107 | | /// from `handle_input_record`. |
1108 | 11 | fn handle_enable_disable_submenu_key<W: WindowsApi>( |
1109 | 11 | &mut self, |
1110 | 11 | windows_api: &W, |
1111 | 11 | clients: &Mutex<Clients>, |
1112 | 11 | workspace_area: &workspace::WorkspaceArea, |
1113 | 11 | key_event: KEY_EVENT_RECORD, |
1114 | 11 | ) { |
1115 | | let ControlModeState::EnableDisableSubmenu { |
1116 | 11 | highlighted_pid, |
1117 | 11 | anchor_col, |
1118 | 11 | } = self.control_mode_state |
1119 | | else { |
1120 | 0 | return; |
1121 | | }; |
1122 | 11 | match classify_enable_disable_submenu_key( |
1123 | 11 | VIRTUAL_KEY(key_event.wVirtualKeyCode), |
1124 | 11 | key_event.dwControlKeyState, |
1125 | 11 | ) { |
1126 | | EnableDisableSubmenuAction::Enable => { |
1127 | 4 | self.update_client_states(clients, |clients_guard| { |
1128 | 4 | return highlighted_pid |
1129 | 4 | .and_then(|pid| return clients_guard3 .get_by_pid3 (pid3 )) |
1130 | 4 | .map(|client| return vec!3 [(client.process_id, ClientState::Active)3 ]) |
1131 | 4 | .unwrap_or_default(); |
1132 | 4 | }); |
1133 | | } |
1134 | | EnableDisableSubmenuAction::Disable => { |
1135 | 1 | self.update_client_states(clients, |clients_guard| { |
1136 | 1 | return highlighted_pid |
1137 | 1 | .and_then(|pid| return clients_guard.get_by_pid(pid)) |
1138 | 1 | .map(|client| return vec![(client.process_id, ClientState::Disabled)]) |
1139 | 1 | .unwrap_or_default(); |
1140 | 1 | }); |
1141 | | } |
1142 | | EnableDisableSubmenuAction::Toggle => { |
1143 | 2 | self.update_client_states(clients, |clients_guard| { |
1144 | 2 | return highlighted_pid |
1145 | 2 | .and_then(|pid| return clients_guard.get_by_pid(pid)) |
1146 | 2 | .map(|client| { |
1147 | 2 | let flipped = match *client.state_sender.borrow() { |
1148 | 1 | ClientState::Active => ClientState::Disabled, |
1149 | 1 | ClientState::Disabled => ClientState::Active, |
1150 | | }; |
1151 | 2 | return vec![(client.process_id, flipped)]; |
1152 | 2 | }) |
1153 | 2 | .unwrap_or_default(); |
1154 | 2 | }); |
1155 | | } |
1156 | 3 | EnableDisableSubmenuAction::Navigate(direction) => { |
1157 | 3 | let clients_guard = clients.lock().unwrap(); |
1158 | 3 | let grid = build_client_grid( |
1159 | 3 | &clients_guard, |
1160 | 3 | workspace_area, |
1161 | 3 | self.config.aspect_ratio_adjustment, |
1162 | 3 | ); |
1163 | 3 | let (next_pid, next_anchor) = next_submenu_selection( |
1164 | 3 | &grid, |
1165 | 3 | highlighted_pid, |
1166 | 3 | anchor_col, |
1167 | 3 | direction, |
1168 | 3 | self.config.submenu_edge_behavior, |
1169 | 3 | ); |
1170 | 3 | self.apply_submenu_highlight(&clients_guard, highlighted_pid, next_pid); |
1171 | 3 | self.control_mode_state = ControlModeState::EnableDisableSubmenu { |
1172 | 3 | highlighted_pid: next_pid, |
1173 | 3 | anchor_col: next_anchor, |
1174 | 3 | }; |
1175 | 3 | self.render_enable_disable_submenu(windows_api); |
1176 | 3 | } |
1177 | 1 | EnableDisableSubmenuAction::NoOp => {} |
1178 | | } |
1179 | 11 | } |
1180 | | |
1181 | | /// Redraws the enable/disable submenu prompt. |
1182 | | /// |
1183 | | /// # Arguments |
1184 | | /// |
1185 | | /// * `windows_api` - Windows API used to clear the console. |
1186 | 3 | fn render_enable_disable_submenu<W: WindowsApi>(&self, windows_api: &W) { |
1187 | 3 | clear_screen(windows_api); |
1188 | 3 | println!("Enable/Disable input (Esc to exit)"); |
1189 | 3 | println!("[e]nable, [d]isable, [t]oggle, arrows/hjkl to move"); |
1190 | 3 | } |
1191 | | |
1192 | | /// Move the per-client highlight from `prev_pid` to `next_pid`. |
1193 | | /// |
1194 | | /// PID-based clearing tolerates the background monitor's `retain` |
1195 | | /// shifting indices while the submenu is open. |
1196 | | /// |
1197 | | /// # Arguments |
1198 | | /// |
1199 | | /// * `clients` - Currently tracked clients. |
1200 | | /// * `prev_pid` - PID currently highlighted, or `None` if no |
1201 | | /// client is highlighted. |
1202 | | /// * `next_pid` - PID to highlight now, or `None` to clear the |
1203 | | /// highlight entirely. |
1204 | 7 | fn apply_submenu_highlight( |
1205 | 7 | &self, |
1206 | 7 | clients: &Clients, |
1207 | 7 | prev_pid: Option<u32>, |
1208 | 7 | next_pid: Option<u32>, |
1209 | 7 | ) { |
1210 | 7 | if let Some(prev_pid6 ) = prev_pid { |
1211 | 6 | if Some(prev_pid) != next_pid { |
1212 | 6 | if let Some(prev_client4 ) = clients.get_by_pid(prev_pid) { |
1213 | 4 | prev_client.highlight_sender.send_replace(false); |
1214 | 4 | }2 |
1215 | 0 | } |
1216 | 1 | } |
1217 | 7 | if let Some(next_pid6 ) = next_pid { |
1218 | 6 | if let Some(client) = clients.get_by_pid(next_pid) { |
1219 | 6 | client.highlight_sender.send_replace(true); |
1220 | 6 | }0 |
1221 | 1 | } |
1222 | 7 | } |
1223 | | |
1224 | | /// Apply a batch of [`ClientState`] updates while holding the |
1225 | | /// [`Clients`] mutex exactly once. |
1226 | | /// |
1227 | | /// `f` is called with the locked guard and returns the list of |
1228 | | /// `(pid, new_state)` updates to apply. The guard is held across both |
1229 | | /// the build and the apply phase so callers see a stable snapshot. |
1230 | | /// |
1231 | | /// # Arguments |
1232 | | /// |
1233 | | /// * `clients` - Shared client collection. |
1234 | | /// * `f` - Builds the updates from a `&Clients` snapshot. |
1235 | 7 | fn update_client_states<F>(&self, clients: &Mutex<Clients>, f: F) |
1236 | 7 | where |
1237 | 7 | F: FnOnce(&Clients) -> Vec<(u32, ClientState)>, |
1238 | | { |
1239 | 7 | let clients_guard = clients.lock().unwrap(); |
1240 | 7 | let updates = f(&clients_guard); |
1241 | 7 | for (pid6 , state6 ) in updates { |
1242 | 6 | self.set_client_state(&clients_guard, pid, state); |
1243 | 6 | } |
1244 | 7 | } |
1245 | | |
1246 | | /// Push a new [`ClientState`] for the client identified by `pid`. |
1247 | | /// |
1248 | | /// Looks the client up by PID and broadcasts the new state through its |
1249 | | /// [`watch::Sender`]. The pipe-server task subscribed to that sender |
1250 | | /// observes the change and forwards a [`crate::protocol::TAG_STATE_CHANGE`] |
1251 | | /// frame to the client over the named pipe. Called from the |
1252 | | /// control-mode handlers for `[t]oggle enabled` and `e[n]able all` via |
1253 | | /// [`Daemon::update_client_states`]. |
1254 | | /// |
1255 | | /// # Arguments |
1256 | | /// |
1257 | | /// * `clients` - The daemon's tracked clients. |
1258 | | /// * `pid` - Process id of the client whose state should change. |
1259 | | /// * `state` - The new state to broadcast. |
1260 | 6 | fn set_client_state(&self, clients: &Clients, pid: u32, state: ClientState) { |
1261 | 6 | if let Some(client) = clients.get_by_pid(pid) { |
1262 | 6 | // `send_replace` always updates the stored value (unlike `send`, |
1263 | 6 | // which returns `Err` and leaves the value untouched when there |
1264 | 6 | // are no active receivers). This matters during the brief window |
1265 | 6 | // between [`Client`] construction and the pipe-server task's |
1266 | 6 | // `subscribe()`: any state change pushed in that window must |
1267 | 6 | // still be visible to the next subscriber via `borrow`. |
1268 | 6 | client.state_sender.send_replace(state); |
1269 | 6 | }0 |
1270 | 6 | } |
1271 | | |
1272 | | /// Re-sizes and re-positions the daemon console window on the screen |
1273 | | /// based on the daemon height configuration. |
1274 | | /// |
1275 | | /// # Arguments |
1276 | | /// |
1277 | | /// * `windows_api` - The Windows API implementation to use |
1278 | | /// * `workspace_area` - The available workspace area on the |
1279 | | /// primary monitor minus the space occupied |
1280 | | /// by the daemon console window. |
1281 | 0 | fn arrange_daemon_console<W: WindowsApi>( |
1282 | 0 | &self, |
1283 | 0 | windows_api: &W, |
1284 | 0 | workspace_area: &WorkspaceArea, |
1285 | 0 | ) { |
1286 | 0 | let (x, y, width, height) = get_console_rect( |
1287 | 0 | 0, |
1288 | 0 | workspace_area.height, |
1289 | 0 | workspace_area.width - (workspace_area.x_fixed_frame + workspace_area.x_size_frame), |
1290 | 0 | self.config.height, |
1291 | 0 | workspace_area, |
1292 | 0 | ); |
1293 | 0 | arrange_console(windows_api, x, y, width, height); |
1294 | 0 | } |
1295 | | } |
1296 | | |
1297 | | /// The processed console input mode controls whether special key combinations |
1298 | | /// such as `Ctrl + c` or `Ctrl + BREAK` receive special handling or are treated |
1299 | | /// as simple key presses. |
1300 | | /// |
1301 | | /// By default processed input mode is enabled, meaning `Ctrl + c` is treated as |
1302 | | /// a signal, not key presses. |
1303 | | /// |
1304 | | /// <https://learn.microsoft.com/en-us/windows/console/ctrl-c-and-ctrl-break-signals> |
1305 | | /// |
1306 | | /// # Arguments |
1307 | | /// |
1308 | | /// * `windows_api` - The Windows API implementation to use |
1309 | 0 | fn toggle_processed_input_mode<W: WindowsApi>(windows_api: &W) { |
1310 | 0 | let handle = get_console_input_buffer(); |
1311 | 0 | let mode = windows_api.get_console_mode(handle).unwrap(); |
1312 | 0 | let new_mode = windows::Win32::System::Console::CONSOLE_MODE(mode.0 ^ ENABLE_PROCESSED_INPUT.0); |
1313 | 0 | let _ = windows_api.set_console_mode(handle, new_mode); |
1314 | 0 | } |
1315 | | |
1316 | | /// Resolve cluster tags into hostnames |
1317 | | /// |
1318 | | /// Iterates over the list of hosts to find and resolve cluster tags. |
1319 | | /// Nested cluster tags are supported but recursivness is not checked for. |
1320 | | /// |
1321 | | /// # Arguments |
1322 | | /// |
1323 | | /// * `hosts` - List of hosts including hostnames and or cluster tags |
1324 | | /// * `clusters` - List of available cluster tags |
1325 | | /// |
1326 | | /// # Returns |
1327 | | /// |
1328 | | /// A list of hostnames |
1329 | 18 | pub fn resolve_cluster_tags<'a>(hosts: Vec<&'a str>, clusters: &'a [Cluster]) -> Vec<&'a str> { |
1330 | 18 | let mut resolved_hosts: Vec<&str> = Vec::new(); |
1331 | | let mut is_cluster_tag: bool; |
1332 | 31 | for host in hosts18 { |
1333 | 31 | is_cluster_tag = false; |
1334 | 31 | for cluster23 in clusters { |
1335 | 23 | if host == cluster.name { |
1336 | 5 | is_cluster_tag = true; |
1337 | 5 | resolved_hosts.extend(resolve_cluster_tags( |
1338 | 9 | cluster.hosts.iter()5 .map5 (|host| return &**host).collect5 (), |
1339 | 5 | clusters, |
1340 | | )); |
1341 | 5 | break; |
1342 | 18 | } |
1343 | | } |
1344 | 31 | if !is_cluster_tag { |
1345 | 26 | resolved_hosts.push(host); |
1346 | 26 | }5 |
1347 | | } |
1348 | 18 | return resolved_hosts; |
1349 | 18 | } |
1350 | | |
1351 | | /// Resolve cluster tags in `hosts` and expand brace expressions |
1352 | | /// (e.g. `host{1..3}.local`) in each resulting hostname. |
1353 | | /// |
1354 | | /// Used by the control-mode `[c]reate window(s)` path so hostname |
1355 | | /// input behaves the same as on the CLI. Each cluster-resolved |
1356 | | /// hostname is passed through [`bracoxide::explode`] individually; |
1357 | | /// hostnames that do not contain a brace expression are kept as-is. |
1358 | | /// |
1359 | | /// # Arguments |
1360 | | /// |
1361 | | /// * `hosts` - User-supplied hostnames and/or cluster tags. |
1362 | | /// * `clusters` - Available cluster definitions. |
1363 | | /// |
1364 | | /// # Returns |
1365 | | /// |
1366 | | /// The fully resolved, brace-expanded list of hostnames. |
1367 | 4 | pub fn expand_hosts(hosts: Vec<&str>, clusters: &[Cluster]) -> Vec<String> { |
1368 | 4 | return resolve_cluster_tags(hosts, clusters) |
1369 | 4 | .into_iter() |
1370 | 7 | .flat_map4 (|host| return explode(host).unwrap_or_else(|_| return vec!4 [host4 .to_owned4 ()])) |
1371 | 4 | .collect(); |
1372 | 4 | } |
1373 | | |
1374 | | /// Launches a client console for each given host and waits for |
1375 | | /// the client windows to exist before returning their handles. |
1376 | | /// |
1377 | | /// # Arguments |
1378 | | /// |
1379 | | /// * `windows_api` - The Windows API implementation to use |
1380 | | /// * `hosts` - List of hosts |
1381 | | /// * `username` - Optional username, if none is given |
1382 | | /// the client will use the SSH config to |
1383 | | /// determine a username. |
1384 | | /// * `port` - Optional port for SSH connections |
1385 | | /// * `debug` - Toggles debug mode on the client. |
1386 | | /// * `workspace_area` - The available workspace area on the primary monitor |
1387 | | /// minus the space occupied by the daemon console window. |
1388 | | /// Used to arrange the client window. |
1389 | | /// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon configuration. |
1390 | | /// Used to arrange the client window. |
1391 | | /// * `index_offset` - Offset used to position the new windows correctly |
1392 | | /// from the start, avoiding flickering. |
1393 | | /// |
1394 | | /// # Returns |
1395 | | /// |
1396 | | /// A [`Clients`] collection preserving the launch order and indexed by |
1397 | | /// process id for pipe-server correlation. |
1398 | 0 | async fn launch_clients<W: WindowsApi + 'static + Clone>( |
1399 | 0 | windows_api: &W, |
1400 | 0 | hosts: Vec<String>, |
1401 | 0 | username: &Option<String>, |
1402 | 0 | port: Option<u16>, |
1403 | 0 | debug: bool, |
1404 | 0 | workspace_area: &workspace::WorkspaceArea, |
1405 | 0 | aspect_ratio_adjustment: f64, |
1406 | 0 | index_offset: usize, |
1407 | 0 | ) -> Clients { |
1408 | 0 | let len_hosts = hosts.len(); |
1409 | 0 | let _guard = WindowsSettingsDefaultTerminalApplicationGuard::new(); |
1410 | | |
1411 | | // Create an Arc to share the windows_api across parallel tasks |
1412 | 0 | let windows_api_arc = Arc::new(windows_api.clone()); |
1413 | | |
1414 | | // Create tasks for each client launch using spawn_blocking to handle the synchronous operations |
1415 | 0 | let mut tasks = Vec::new(); |
1416 | | |
1417 | 0 | for (index, host) in hosts.into_iter().enumerate() { |
1418 | 0 | let username_client = username.clone(); |
1419 | 0 | let workspace_area_client = *workspace_area; |
1420 | 0 | let windows_api_clone = Arc::clone(&windows_api_arc); |
1421 | | |
1422 | | // Use spawn_blocking to run the synchronous launch_client_console in parallel |
1423 | 0 | let task = tokio::task::spawn_blocking(move || { |
1424 | 0 | let (window_handle, process_handle, process_id) = launch_client_console( |
1425 | 0 | windows_api_clone.as_ref(), |
1426 | 0 | &host, |
1427 | 0 | username_client, |
1428 | 0 | port, |
1429 | 0 | debug, |
1430 | 0 | index + index_offset, |
1431 | 0 | &workspace_area_client, |
1432 | 0 | len_hosts + index_offset, |
1433 | 0 | aspect_ratio_adjustment, |
1434 | 0 | ); |
1435 | | // The receivers are dropped immediately; pipe-server tasks |
1436 | | // acquire their own receivers via `subscribe()` after PID |
1437 | | // correlation. Holding the senders on the [`Client`] keeps both |
1438 | | // channels alive for the lifetime of the client. |
1439 | 0 | let (state_sender, _state_receiver) = watch::channel(ClientState::Active); |
1440 | 0 | let (highlight_sender, _highlight_receiver) = watch::channel(false); |
1441 | 0 | return ( |
1442 | 0 | index, |
1443 | 0 | Client { |
1444 | 0 | hostname: host, |
1445 | 0 | window_handle, |
1446 | 0 | process_handle, |
1447 | 0 | process_id, |
1448 | 0 | state_sender, |
1449 | 0 | highlight_sender, |
1450 | 0 | // Placeholder - `Clients::push` overwrites with the |
1451 | 0 | // dense `list.len()`-based tile index. |
1452 | 0 | tile_index: 0, |
1453 | 0 | }, |
1454 | 0 | ); |
1455 | 0 | }); |
1456 | | |
1457 | 0 | tasks.push(task); |
1458 | | } |
1459 | | |
1460 | | // Wait for all tasks to complete in parallel |
1461 | 0 | let mut results = Vec::new(); |
1462 | 0 | for task in tasks { |
1463 | 0 | match task.await { |
1464 | 0 | Ok(result) => results.push(result), |
1465 | 0 | Err(e) => panic!("Failed to launch client: {e}"), |
1466 | | } |
1467 | | } |
1468 | | |
1469 | | // Sort results by index to maintain order |
1470 | 0 | results.sort_by_key(|(index, _)| return *index); |
1471 | | |
1472 | 0 | let mut clients = Clients::new(); |
1473 | 0 | for (_, client) in results.into_iter() { |
1474 | 0 | clients.push(client); |
1475 | 0 | } |
1476 | 0 | return clients; |
1477 | 0 | } |
1478 | | |
1479 | | /// Launchs a `client` console process with its own window with the given |
1480 | | /// CLI arguments/options: `host`, `username`, `port`, `debug`. |
1481 | | /// |
1482 | | /// Waits for the window to open, then re-arranges it based on |
1483 | | /// the total number of clients, the size of the daemon console window and |
1484 | | /// its index relative to the other client windows. |
1485 | | /// |
1486 | | /// # Arguments |
1487 | | /// |
1488 | | /// * `windows_api` - The Windows API implementation to use |
1489 | | /// * `host` - Hostname the client should connect to |
1490 | | /// * `username` - Username the client should use |
1491 | | /// * `port` - Optional port for SSH connections |
1492 | | /// * `debug` - Toggle debug mode on the client |
1493 | | /// * `index` - The index of the client in the list of all clients. |
1494 | | /// Used to re-arrange the client window. |
1495 | | /// * `workspace_area` - The available workspace area on the primary monitor |
1496 | | /// minus the space occupied by the daemon console window. |
1497 | | /// * `number_of_consoles` - The total number of active client console windows. |
1498 | | /// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon configuration. |
1499 | | /// |
1500 | | /// # Returns |
1501 | | /// |
1502 | | /// A tuple containing the window handle, process handle, and process id of the |
1503 | | /// client process. |
1504 | 0 | fn launch_client_console<W: WindowsApi>( |
1505 | 0 | windows_api: &W, |
1506 | 0 | host: &str, |
1507 | 0 | username: Option<String>, |
1508 | 0 | port: Option<u16>, |
1509 | 0 | debug: bool, |
1510 | 0 | index: usize, |
1511 | 0 | workspace_area: &workspace::WorkspaceArea, |
1512 | 0 | number_of_consoles: usize, |
1513 | 0 | aspect_ratio_adjustment: f64, |
1514 | 0 | ) -> (HWND, HANDLE, u32) { |
1515 | | // The first argument must be `--` to ensure all following arguments are treated |
1516 | | // as positional arguments and not as options if they start with `-`. |
1517 | 0 | let mut client_args: Vec<String> = Vec::new(); |
1518 | 0 | if debug { |
1519 | 0 | client_args.push("-d".to_string()); |
1520 | 0 | } |
1521 | 0 | let mut actual_host = host; |
1522 | 0 | let mut actual_username = username; |
1523 | 0 | if let Some(split_result) = host.split_once("@") { |
1524 | 0 | actual_username = Some(split_result.0.to_owned()); |
1525 | 0 | actual_host = split_result.1; |
1526 | 0 | } |
1527 | 0 | if let Some(actual_username) = actual_username.as_deref() { |
1528 | 0 | client_args.extend(vec!["-u".to_string(), actual_username.to_string()]); |
1529 | 0 | } |
1530 | 0 | if let Some(port) = port { |
1531 | 0 | client_args.extend(vec!["-p".to_string(), port.to_string()]); |
1532 | 0 | } |
1533 | 0 | client_args.push("client".to_string()); |
1534 | 0 | client_args.extend(vec!["--".to_string(), actual_host.to_string()]); |
1535 | | |
1536 | 0 | let process_info = spawn_console_process(windows_api, ¤t_exe_path(), client_args, false) |
1537 | 0 | .expect("Failed to create process"); |
1538 | 0 | let client_window_handle = get_console_window_handle(windows_api, process_info.dwProcessId); |
1539 | 0 | let process_handle = windows_api |
1540 | 0 | .open_process(PROCESS_QUERY_INFORMATION.0, false, process_info.dwProcessId) |
1541 | 0 | .unwrap_or_else(|err| { |
1542 | 0 | panic!( |
1543 | | "Failed to open process handle for process {}: {}", |
1544 | | process_info.dwProcessId, err |
1545 | | ); |
1546 | | }); |
1547 | | |
1548 | 0 | arrange_client_window( |
1549 | 0 | windows_api, |
1550 | 0 | &client_window_handle, |
1551 | 0 | workspace_area, |
1552 | 0 | index, |
1553 | 0 | number_of_consoles, |
1554 | 0 | aspect_ratio_adjustment, |
1555 | | ); |
1556 | 0 | return ( |
1557 | 0 | client_window_handle, |
1558 | 0 | process_handle, |
1559 | 0 | process_info.dwProcessId, |
1560 | 0 | ); |
1561 | 0 | } |
1562 | | |
1563 | | /// Correlate the connecting client by PID, then multiplex input records, |
1564 | | /// [`ClientState`] updates, and keep-alives onto the named pipe. |
1565 | | /// |
1566 | | /// The post-subscribe initial-state push is intentional: `state_receiver.changed` |
1567 | | /// only fires on transitions observed *after* `subscribe`, so a state set |
1568 | | /// in the brief window between [`Client`] construction and `subscribe` |
1569 | | /// would otherwise leave the client on its default until the next |
1570 | | /// transition. |
1571 | | /// |
1572 | | /// The `select!` is biased toward `recv` so the keep-alive tick never |
1573 | | /// preempts active input traffic; the [`ClientState::Disabled`] arm |
1574 | | /// therefore probes the pipe itself, otherwise sustained input would |
1575 | | /// hide a disconnect. |
1576 | | /// |
1577 | | /// # Errors and termination |
1578 | | /// |
1579 | | /// An unknown PID exits the process (production) or panics (tests) - |
1580 | | /// the daemon's bookkeeping is broken and recovery is not possible. |
1581 | | /// A failed pipe write or a dropped [`watch::Sender`] ends the routine |
1582 | | /// cleanly. |
1583 | 8 | async fn named_pipe_server_routine( |
1584 | 8 | server: NamedPipeServer, |
1585 | 8 | receiver: &mut Receiver<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>, |
1586 | 8 | clients: Arc<Mutex<Clients>>, |
1587 | 8 | ) { |
1588 | | // wait for a client to connect |
1589 | 8 | server.connect().await.unwrap_or_else(|err| {0 |
1590 | 0 | error!("{}", err); |
1591 | 0 | panic!("Timed out waiting for clients to connect to named pipe server",) |
1592 | | }); |
1593 | | |
1594 | | // Correlate the connecting client by reading its 4 byte PID. |
1595 | 8 | let pid7 = read_client_pid(&server).await; |
1596 | 7 | let (mut state_receiver6 , mut highlight_receiver6 ) = match clients.lock().unwrap().get_by_pid(pid) |
1597 | | { |
1598 | 6 | Some(client) => ( |
1599 | 6 | client.state_sender.subscribe(), |
1600 | 6 | client.highlight_sender.subscribe(), |
1601 | 6 | ), |
1602 | | None => { |
1603 | 1 | error!( |
1604 | | "Named pipe server received unknown PID {} - daemon bookkeeping broken", |
1605 | | pid |
1606 | | ); |
1607 | | // In production this exits the daemon; in tests process::exit would kill |
1608 | | // the test runner, so we panic instead so tokio::spawn can catch it. |
1609 | | #[cfg(not(test))] |
1610 | | std::process::exit(1); |
1611 | | #[cfg(test)] |
1612 | 1 | panic!("Unknown client PID {} - daemon bookkeeping broken", pid); |
1613 | | } |
1614 | | }; |
1615 | | |
1616 | | // Initial state push - see fn docs. |
1617 | 6 | let initial_state = *state_receiver.borrow_and_update(); |
1618 | 6 | let initial_state_frame: [u8; FRAMED_STATE_CHANGE_LENGTH] = |
1619 | 6 | [TAG_STATE_CHANGE, serialize_client_state(initial_state)]; |
1620 | 6 | if !write_framed_message(&server, &initial_state_frame).await { |
1621 | 0 | return; |
1622 | 6 | } |
1623 | | |
1624 | | // Initial highlight push - same rationale as the state push above. |
1625 | 6 | let initial_highlight = *highlight_receiver.borrow_and_update(); |
1626 | 6 | let initial_highlight_frame: [u8; FRAMED_HIGHLIGHT_LENGTH] = |
1627 | 6 | [TAG_HIGHLIGHT, serialize_highlight(initial_highlight)]; |
1628 | 6 | if !write_framed_message(&server, &initial_highlight_frame).await { |
1629 | 0 | return; |
1630 | 6 | } |
1631 | | |
1632 | | loop { |
1633 | | // Independent watch channels: `state_receiver` and `highlight_receiver` are forwarded over the pipe in whichever order this `select!` happens to pick them up, not the order the daemon-side senders fired. |
1634 | 24 | tokio::select! { |
1635 | | biased; |
1636 | 24 | recv_result16 = receiver.recv() => { |
1637 | 14 | let ser_input_record = match recv_result2 { |
1638 | 14 | Ok(val) => val, |
1639 | 1 | Err(RecvError::Lagged(skipped)) => { |
1640 | | // Slow consumers (typically disabled clients) drop |
1641 | | // records rather than kill the routine; debug-level |
1642 | | // because this can fire repeatedly under load. |
1643 | 1 | debug!( |
1644 | | "Named pipe server routine lagged behind broadcast channel - dropping {} record(s)", |
1645 | | skipped |
1646 | | ); |
1647 | | // Probe and yield so sustained lag cannot starve |
1648 | | // the keep-alive tick (the `select!` is `biased` |
1649 | | // toward `recv`) and so a closed pipe is still |
1650 | | // detected promptly under load. |
1651 | 1 | if !probe_pipe_alive(&server) { |
1652 | 0 | return; |
1653 | 1 | } |
1654 | 1 | tokio::task::yield_now().await; |
1655 | 1 | continue; |
1656 | | } |
1657 | | Err(RecvError::Closed) => { |
1658 | 1 | error!("Broadcast channel closed"); |
1659 | 1 | panic!("Failed to receive data from the Receiver"); |
1660 | | } |
1661 | | }; |
1662 | | // Copy out before any `.await` - `watch::Ref` is not `Send`. |
1663 | 14 | let current_state = *state_receiver.borrow(); |
1664 | 14 | match current_state { |
1665 | 7 | ClientState::Active => {} |
1666 | | ClientState::Disabled => { |
1667 | | // Probe the pipe so a disabled client cannot hide a |
1668 | | // disconnect under sustained input - the keep-alive |
1669 | | // tick is starved while recv keeps yielding records. |
1670 | 7 | if !probe_pipe_alive(&server) { |
1671 | 0 | return; |
1672 | 7 | } |
1673 | 7 | tokio::task::yield_now().await; |
1674 | 7 | continue; |
1675 | | } |
1676 | | } |
1677 | 7 | let mut frame = [0u8; FRAMED_INPUT_RECORD_LENGTH]; |
1678 | 7 | frame[0] = TAG_INPUT_RECORD; |
1679 | 7 | frame[1..].copy_from_slice(&ser_input_record); |
1680 | 7 | if !write_framed_message(&server, &frame).await { |
1681 | 0 | return; |
1682 | 7 | } |
1683 | | } |
1684 | 24 | changed_result2 = state_receiver.changed() => { |
1685 | | // Sender dropped - the daemon has removed this client from its |
1686 | | // bookkeeping, so there is nothing left to forward. |
1687 | 2 | if changed_result.is_err() { |
1688 | 0 | debug!( |
1689 | | "Client state sender dropped, stopping named pipe server routine ({:?})", |
1690 | | server |
1691 | | ); |
1692 | 0 | return; |
1693 | 2 | } |
1694 | 2 | let state = *state_receiver.borrow_and_update(); |
1695 | 2 | let frame: [u8; FRAMED_STATE_CHANGE_LENGTH] = |
1696 | 2 | [TAG_STATE_CHANGE, serialize_client_state(state)]; |
1697 | 2 | if !write_framed_message(&server, &frame).await { |
1698 | 0 | return; |
1699 | 2 | } |
1700 | | } |
1701 | 24 | changed_result0 = highlight_receiver.changed() => { |
1702 | | // Sender dropped - same rationale as the `state_receiver` arm. |
1703 | 0 | if changed_result.is_err() { |
1704 | 0 | debug!( |
1705 | | "Client highlight sender dropped, stopping named pipe server routine ({:?})", |
1706 | | server |
1707 | | ); |
1708 | 0 | return; |
1709 | 0 | } |
1710 | 0 | let highlighted = *highlight_receiver.borrow_and_update(); |
1711 | 0 | let frame: [u8; FRAMED_HIGHLIGHT_LENGTH] = |
1712 | 0 | [TAG_HIGHLIGHT, serialize_highlight(highlighted)]; |
1713 | 0 | if !write_framed_message(&server, &frame).await { |
1714 | 0 | return; |
1715 | 0 | } |
1716 | | } |
1717 | 24 | _ = tokio::time::sleep(Duration::from_millis(5)) => { |
1718 | 6 | if !write_framed_message(&server, &[TAG_KEEP_ALIVE]).await { |
1719 | 5 | return; |
1720 | 1 | } |
1721 | | } |
1722 | | } |
1723 | | } |
1724 | 5 | } |
1725 | | |
1726 | | /// Best-effort, non-blocking probe of the named pipe. |
1727 | | /// |
1728 | | /// Returns `true` if a single `TAG_KEEP_ALIVE` byte either wrote |
1729 | | /// successfully or returned `WouldBlock` (the pipe is still open but |
1730 | | /// the OS buffer is full); `false` if any other error indicates the |
1731 | | /// pipe is closed. |
1732 | 8 | fn probe_pipe_alive(server: &NamedPipeServer) -> bool { |
1733 | 8 | match server.try_write(&[TAG_KEEP_ALIVE]) { |
1734 | 7 | Ok(_) => return true, |
1735 | 1 | Err(e) if e.kind() == io::ErrorKind::WouldBlock => return true, |
1736 | | Err(_) => { |
1737 | 0 | debug!( |
1738 | | "Named pipe server ({:?}) is closed, stopping named pipe server routine", |
1739 | | server |
1740 | | ); |
1741 | 0 | return false; |
1742 | | } |
1743 | | } |
1744 | 8 | } |
1745 | | |
1746 | | /// Write all of `frame` to the named pipe server, retrying partial |
1747 | | /// writes and `WouldBlock` results until the buffer is fully drained. |
1748 | | /// |
1749 | | /// Returns `true` on full write, `false` if the pipe is closed. |
1750 | | /// |
1751 | | /// # Panics |
1752 | | /// |
1753 | | /// Panics if waiting for the pipe to become writable returns an error. |
1754 | 27 | async fn write_framed_message(server: &NamedPipeServer, frame: &[u8]) -> bool { |
1755 | 27 | let mut written = 0usize; |
1756 | 63 | while written < frame.len() { |
1757 | 41 | server.writable().await.unwrap_or_else(|err| {0 |
1758 | 0 | error!("{}", err); |
1759 | 0 | panic!("Timed out waiting for named pipe server to become writable",) |
1760 | | }); |
1761 | 41 | match server.try_write(&frame[written..]) { |
1762 | 22 | Ok(n) => { |
1763 | 22 | written += n; |
1764 | 22 | if written < frame.len() { |
1765 | 0 | warn!( |
1766 | | "Partially written data, expected {} but only wrote {} so far", |
1767 | 0 | frame.len(), |
1768 | | written |
1769 | | ); |
1770 | 22 | } |
1771 | | } |
1772 | 19 | Err(e14 ) if e.kind() == io::ErrorKind::WouldBlock14 => { |
1773 | | // Try again |
1774 | 14 | debug!("Writing to named pipe server would have blocked"); |
1775 | 14 | continue; |
1776 | | } |
1777 | | Err(_) => { |
1778 | | // Can happen if the pipe is closed because the |
1779 | | // client exited |
1780 | 5 | debug!( |
1781 | | "Named pipe server ({:?}) is closed, stopping named pipe server routine", |
1782 | | server |
1783 | | ); |
1784 | 5 | return false; |
1785 | | } |
1786 | | } |
1787 | | } |
1788 | 22 | debug!("Successfully written all data"); |
1789 | 22 | return true; |
1790 | 27 | } |
1791 | | |
1792 | | /// Read the connecting client's 4 byte little-endian process id from the pipe. |
1793 | | /// |
1794 | | /// Reads exactly 4 bytes from `server`, retrying on `WouldBlock`, and decodes |
1795 | | /// them as a `u32`. Any non-recoverable I/O error panics, as a client that |
1796 | | /// cannot send its PID cannot be correlated and forwarding would be |
1797 | | /// impossible. |
1798 | | /// |
1799 | | /// # Arguments |
1800 | | /// |
1801 | | /// * `server` - The connected named pipe server to read from. |
1802 | | /// |
1803 | | /// # Returns |
1804 | | /// |
1805 | | /// The process id sent by the client. |
1806 | | /// |
1807 | | /// # Panics |
1808 | | /// |
1809 | | /// Panics if the pipe is closed before 4 bytes can be read, or if any |
1810 | | /// non-`WouldBlock` I/O error occurs. |
1811 | 8 | async fn read_client_pid(server: &NamedPipeServer) -> u32 { |
1812 | 8 | let mut buf = [0u8; SERIALIZED_PID_LENGTH]; |
1813 | 8 | let mut read = 0usize; |
1814 | 15 | while read < SERIALIZED_PID_LENGTH { |
1815 | 8 | server.readable().await.unwrap_or_else(|err| {0 |
1816 | 0 | panic!("Named pipe server is not readable for PID handshake: {err}") |
1817 | | }); |
1818 | 8 | match server.try_read(&mut buf[read..]) { |
1819 | | Ok(0) => { |
1820 | 1 | panic!("Named pipe server closed before PID handshake completed"); |
1821 | | } |
1822 | 7 | Ok(n) => { |
1823 | 7 | read += n; |
1824 | 7 | } |
1825 | 0 | Err(e) if e.kind() == io::ErrorKind::WouldBlock => { |
1826 | 0 | continue; |
1827 | | } |
1828 | 0 | Err(e) => { |
1829 | 0 | panic!("Failed to read PID from named pipe client: {e}"); |
1830 | | } |
1831 | | } |
1832 | | } |
1833 | 7 | return deserialize_pid(&buf); |
1834 | 7 | } |
1835 | | |
1836 | | /// Re-sizes and re-positions the given client window based on the total number of clients, |
1837 | | /// the size of the daemon console window and its index relative to the other client windows. |
1838 | | /// |
1839 | | /// # Arguments |
1840 | | /// |
1841 | | /// * `windows_api` - The Windows API implementation to use |
1842 | | /// * `handle` - Reference the windows handle of a client console window. |
1843 | | /// * `workspace_area` - The available workspace area on the primary monitor |
1844 | | /// minus the space occupied by the daemon console window. |
1845 | | /// * `index` - The index of the client in the list of all clients. |
1846 | | /// * `number_of_consoles` - The total number of active client console windows. |
1847 | | /// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon configuration. |
1848 | 0 | fn arrange_client_window<W: WindowsApi>( |
1849 | 0 | windows_api: &W, |
1850 | 0 | handle: &HWND, |
1851 | 0 | workspace_area: &workspace::WorkspaceArea, |
1852 | 0 | index: usize, |
1853 | 0 | number_of_consoles: usize, |
1854 | 0 | aspect_ratio_adjustment: f64, |
1855 | 0 | ) { |
1856 | 0 | let (x, y, width, height) = determine_client_spatial_attributes( |
1857 | 0 | index as i32, |
1858 | 0 | number_of_consoles as i32, |
1859 | 0 | workspace_area, |
1860 | 0 | aspect_ratio_adjustment, |
1861 | 0 | ); |
1862 | | // Since windows update 10.0.19041.5072 it can happen that a client windows rendering is broken |
1863 | | // after a move+resize. Why is unclear, but resizing again does solve the issue. |
1864 | | // We first make the window 1 pixel in each dimension too small and imediately fix it. |
1865 | | // To reduce overhead we do not repaint the window the first time. |
1866 | 0 | windows_api |
1867 | 0 | .move_window(*handle, x, y, width - 1, height - 1, false) |
1868 | 0 | .unwrap_or_else(|err| { |
1869 | 0 | error!("{}", err); |
1870 | 0 | panic!("Failed to move window",) |
1871 | | }); |
1872 | 0 | windows_api |
1873 | 0 | .move_window(*handle, x, y, width, height, true) |
1874 | 0 | .unwrap_or_else(|err| { |
1875 | 0 | error!("{}", err); |
1876 | 0 | panic!("Failed to move window",) |
1877 | | }); |
1878 | 0 | } |
1879 | | |
1880 | | /// Return the workspace area's aspect ratio (width / height) including |
1881 | | /// the frame padding the tiler accounts for. |
1882 | | /// |
1883 | | /// # Arguments |
1884 | | /// |
1885 | | /// * `workspace_area` - Available workspace minus the daemon console. |
1886 | | /// |
1887 | | /// # Returns |
1888 | | /// |
1889 | | /// Aspect ratio as a `f64` for use by both the tiler and the navigation |
1890 | | /// grid. |
1891 | 3 | fn workspace_aspect_ratio(workspace_area: &workspace::WorkspaceArea) -> f64 { |
1892 | 3 | return (workspace_area.width + (workspace_area.x_fixed_frame + workspace_area.x_size_frame) * 2) |
1893 | 3 | as f64 |
1894 | 3 | / (workspace_area.height + (workspace_area.y_fixed_frame + workspace_area.y_size_frame) * 2) |
1895 | 3 | as f64; |
1896 | 3 | } |
1897 | | |
1898 | | /// Calculates the position and dimensions for a client window given its index, |
1899 | | /// the total number of clients and the `aspect_ratio_adjustment` daemon configuration. |
1900 | | /// |
1901 | | /// # Arguments |
1902 | | /// |
1903 | | /// * `index` - The index of the client in the list of all clients. |
1904 | | /// * `number_of_consoles` - The total number of active client console windows. |
1905 | | /// * `workspace_area` - The available workspace area on the primary monitor |
1906 | | /// minus the space occupied by the daemon console window. |
1907 | | /// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon configuration. |
1908 | | /// * `> 0.0` - Aims for vertical rectangle shape. |
1909 | | /// The larger the value, the more exaggerated the "verticality". |
1910 | | /// Eventually the windows will all be columns. |
1911 | | /// * `= 0.0` - Aims for square shape. |
1912 | | /// * `< 0.0` - Aims for horizontal rectangle shape. |
1913 | | /// The smaller the value, the more exaggerated the "horizontality". |
1914 | | /// Eventually the windows will all be rows. |
1915 | | /// `-1.0` is the sweetspot for mostly preserving a 16:9 ratio. |
1916 | 0 | fn determine_client_spatial_attributes( |
1917 | 0 | index: i32, |
1918 | 0 | number_of_consoles: i32, |
1919 | 0 | workspace_area: &workspace::WorkspaceArea, |
1920 | 0 | aspect_ratio_adjustment: f64, |
1921 | 0 | ) -> (i32, i32, i32, i32) { |
1922 | 0 | let aspect_ratio = workspace_aspect_ratio(workspace_area); |
1923 | 0 | let (grid_columns, grid_rows) = |
1924 | 0 | grid_dimensions(number_of_consoles, aspect_ratio, aspect_ratio_adjustment); |
1925 | | |
1926 | 0 | let grid_column_index = index % grid_columns; |
1927 | 0 | let grid_row_index = index / grid_columns; |
1928 | | |
1929 | 0 | let is_last_row = grid_row_index == grid_rows - 1; |
1930 | 0 | let last_row_console_count = number_of_consoles % grid_columns; |
1931 | | |
1932 | 0 | let console_width = if is_last_row && last_row_console_count != 0 { |
1933 | 0 | (workspace_area.width / last_row_console_count) |
1934 | 0 | + if last_row_console_count > 1 { |
1935 | 0 | workspace_area.x_fixed_frame + workspace_area.x_size_frame |
1936 | | } else { |
1937 | 0 | 0 |
1938 | | } |
1939 | | } else { |
1940 | 0 | (workspace_area.width / grid_columns) |
1941 | 0 | + (workspace_area.x_fixed_frame + workspace_area.x_size_frame) |
1942 | | }; |
1943 | | |
1944 | 0 | let console_height = (workspace_area.height |
1945 | 0 | + (workspace_area.y_fixed_frame + workspace_area.y_size_frame) * grid_row_index) |
1946 | 0 | / grid_rows; |
1947 | | |
1948 | 0 | let x = grid_column_index * console_width |
1949 | 0 | - ((workspace_area.x_fixed_frame + workspace_area.x_size_frame) * (grid_column_index + 1)); |
1950 | 0 | let y = grid_row_index * console_height |
1951 | 0 | - ((workspace_area.y_fixed_frame + workspace_area.y_size_frame) * (grid_row_index - 1)); |
1952 | | |
1953 | 0 | return get_console_rect(x, y, console_width, console_height, workspace_area); |
1954 | 0 | } |
1955 | | |
1956 | | /// Transform the position and dimensions of a console window based |
1957 | | /// on the workspace area. |
1958 | | /// |
1959 | | /// To minimize empty space between windows, width and height must be adjusted |
1960 | | /// by the `fixed_frame` and `size_frame` values. |
1961 | | /// |
1962 | | /// # Arguments |
1963 | | /// |
1964 | | /// * `x` - The `x` coordinate of the window. |
1965 | | /// * `y` - The `y` coordinate of the window. |
1966 | | /// * `width` - The `width` in pixels of the window. |
1967 | | /// * `height` - The `height` in pixels of the window. |
1968 | | /// * `workspace_area` - The available workspace area on the primary monitor minus |
1969 | | /// the space occupied by the daemon console window. |
1970 | | /// |
1971 | | /// # Returns |
1972 | | /// |
1973 | | /// (`x`, `y`, `width`, `height`) |
1974 | | /// |
1975 | 0 | fn get_console_rect( |
1976 | 0 | x: i32, |
1977 | 0 | y: i32, |
1978 | 0 | width: i32, |
1979 | 0 | height: i32, |
1980 | 0 | workspace_area: &workspace::WorkspaceArea, |
1981 | 0 | ) -> (i32, i32, i32, i32) { |
1982 | 0 | return ( |
1983 | 0 | std::cmp::max( |
1984 | 0 | workspace_area.x - (workspace_area.x_fixed_frame + workspace_area.x_size_frame), |
1985 | 0 | workspace_area.x - (workspace_area.x_fixed_frame + workspace_area.x_size_frame) + x, |
1986 | 0 | ), |
1987 | 0 | workspace_area.y - (workspace_area.y_fixed_frame + workspace_area.y_size_frame) + y, |
1988 | 0 | std::cmp::min(workspace_area.width, width), |
1989 | 0 | height, |
1990 | 0 | ); |
1991 | 0 | } |
1992 | | |
1993 | | /// Spawns a background thread that ensures the z-order of all client |
1994 | | /// windows is in sync with the daemon window. |
1995 | | /// I.e. if the daemon window is focussed, all clients should be moved to the foreground. |
1996 | | /// |
1997 | | /// # Arguments |
1998 | | /// |
1999 | | /// * `windows_api` - Arc-wrapped Windows API implementation for thread-safe access |
2000 | | /// * `clients` - A thread safe mapping from the number |
2001 | | /// a client console window was launched at |
2002 | | /// in relation to the other client windows |
2003 | | /// and the clients console window handle. |
2004 | | /// The mapping must be thread safe to allow |
2005 | | /// it to be modified by the main thread |
2006 | | /// while we periodically read from it in the |
2007 | | /// background thread. |
2008 | 0 | fn ensure_client_z_order_in_sync_with_daemon<W: WindowsApi + Send + Sync + 'static>( |
2009 | 0 | windows_api: Arc<W>, |
2010 | 0 | clients: Arc<Mutex<Clients>>, |
2011 | 0 | ) { |
2012 | 0 | tokio::spawn(async move { |
2013 | 0 | let daemon_handle = get_console_window_wrapper(windows_api.as_ref()); |
2014 | 0 | let mut previous_foreground_window = get_foreground_window_wrapper(windows_api.as_ref()); |
2015 | | loop { |
2016 | 0 | tokio::time::sleep(Duration::from_millis(1)).await; |
2017 | 0 | let foreground_window = get_foreground_window_wrapper(windows_api.as_ref()); |
2018 | 0 | if previous_foreground_window == foreground_window { |
2019 | 0 | continue; |
2020 | 0 | } |
2021 | 0 | if foreground_window == daemon_handle |
2022 | 0 | && !clients.lock().unwrap().iter().any(|client| { |
2023 | 0 | return client.window_handle == previous_foreground_window.hwdn |
2024 | 0 | || client.window_handle == daemon_handle.hwdn; |
2025 | 0 | }) |
2026 | 0 | { |
2027 | 0 | defer_windows( |
2028 | 0 | windows_api.as_ref(), |
2029 | 0 | &clients.lock().unwrap(), |
2030 | 0 | &daemon_handle.hwdn, |
2031 | 0 | ); |
2032 | 0 | } |
2033 | 0 | previous_foreground_window = foreground_window; |
2034 | | } |
2035 | | }); |
2036 | 0 | } |
2037 | | |
2038 | | /// Move all given windows to the foreground. |
2039 | | /// |
2040 | | /// Restores minimized windows. |
2041 | | /// If a window handle no longer points to a valid window, it is skipped. |
2042 | | /// The daemon window is deferred last and receives focus. |
2043 | | /// |
2044 | | /// # Arguments |
2045 | | /// |
2046 | | /// * `windows_api` - The Windows API implementation to use |
2047 | | /// * `clients` - A thread safe mapping from the number |
2048 | | /// a client console window was launched at |
2049 | | /// in relation to the other client windows |
2050 | | /// and the clients console window handle. |
2051 | | /// * `daemon_handle` - Handle to the daemon console window. |
2052 | 0 | fn defer_windows<W: WindowsApi>(windows_api: &W, clients: &[Client], daemon_handle: &HWND) { |
2053 | 0 | for client in clients.iter() { |
2054 | 0 | restore_if_minimized(windows_api, client.window_handle, false); |
2055 | 0 | let _ = windows_api.bring_window_to_top(client.window_handle, false); |
2056 | 0 | } |
2057 | | // Raise the daemon last so it ends up on top and keeps keyboard focus. |
2058 | 0 | restore_if_minimized(windows_api, *daemon_handle, true); |
2059 | 0 | let _ = windows_api.bring_window_to_top(*daemon_handle, true); |
2060 | 0 | } |
2061 | | |
2062 | | /// Restore `window_handle` if its current placement reports minimized. |
2063 | | /// |
2064 | | /// Silently does nothing when the placement query fails or the window is |
2065 | | /// not minimized. Used by [`defer_windows`] so both client and daemon |
2066 | | /// windows are brought back from the taskbar before z-order updates. |
2067 | | /// |
2068 | | /// # Arguments |
2069 | | /// |
2070 | | /// * `windows_api` - Windows API implementation. |
2071 | | /// * `window_handle` - Handle to the window to potentially restore. |
2072 | | /// * `with_keyboard_focus` - Whether the restored window should be activated. |
2073 | | /// Pass `false` for client windows so unminimizing |
2074 | | /// them does not steal foreground from the daemon - |
2075 | | /// `SW_RESTORE` activates, which would let the |
2076 | | /// last-restored client win the foreground race and |
2077 | | /// block [`WindowsApi::bring_window_to_top`] from |
2078 | | /// refocusing the daemon. |
2079 | 0 | fn restore_if_minimized<W: WindowsApi>( |
2080 | 0 | windows_api: &W, |
2081 | 0 | window_handle: HWND, |
2082 | 0 | with_keyboard_focus: bool, |
2083 | 0 | ) { |
2084 | 0 | let placement = match windows_api.get_window_placement(window_handle) { |
2085 | 0 | Ok(placement) => placement, |
2086 | 0 | Err(_) => return, |
2087 | | }; |
2088 | 0 | if placement.showCmd == SW_SHOWMINIMIZED.0.try_into().unwrap() { |
2089 | 0 | let cmd = if with_keyboard_focus { |
2090 | 0 | SW_RESTORE |
2091 | | } else { |
2092 | 0 | SW_SHOWNOACTIVATE |
2093 | | }; |
2094 | 0 | let _ = windows_api.show_window(window_handle, cmd); |
2095 | 0 | } |
2096 | 0 | } |
2097 | | |
2098 | | /// The entrypoint for the `daemon` subcommand. |
2099 | | /// |
2100 | | /// Spawns 1 client process with its own window for each host |
2101 | | /// and 1 worker thread that handles communication with the client |
2102 | | /// over a named pipe. |
2103 | | /// Responsible for client window positioning and sizing. |
2104 | | /// Handles control mode. |
2105 | | /// Main thread reads input records from the console input buffer |
2106 | | /// and propagates them via the background threads to all clients |
2107 | | /// simultaneously. |
2108 | | /// |
2109 | | /// # Arguments |
2110 | | /// |
2111 | | /// * `windows_api` - The Windows API implementation to use |
2112 | | /// * `hosts` - List of hostnames for which to launch clients. |
2113 | | /// * `username` - Username used to connect to the hosts. |
2114 | | /// If none, each client will use the SSH config to determine |
2115 | | /// a suitable username for their respective host. |
2116 | | /// * `port` - Optional port used for all SSH connections. |
2117 | | /// * `config` - The `DaemonConfig`. |
2118 | | /// * `debug` - Enables debug logging |
2119 | 0 | pub async fn main<W: WindowsApi + Clone + 'static>( |
2120 | 0 | windows_api: &W, |
2121 | 0 | hosts: Vec<String>, |
2122 | 0 | username: Option<String>, |
2123 | 0 | port: Option<u16>, |
2124 | 0 | config: &DaemonConfig, |
2125 | 0 | clusters: &[Cluster], |
2126 | 0 | debug: bool, |
2127 | 0 | ) { |
2128 | 0 | let daemon: Daemon = Daemon { |
2129 | 0 | hosts: explode(&hosts.join(" ")).unwrap_or(hosts), |
2130 | 0 | username, |
2131 | 0 | port, |
2132 | 0 | config, |
2133 | 0 | clusters, |
2134 | 0 | control_mode_state: ControlModeState::Inactive, |
2135 | 0 | debug, |
2136 | 0 | }; |
2137 | 0 | daemon.launch(windows_api).await; |
2138 | 0 | debug!("Actually exiting"); |
2139 | 0 | } |
2140 | | |
2141 | | #[cfg(test)] |
2142 | | #[path = "../tests/daemon/test_mod.rs"] |
2143 | | mod test_mod; |